МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970 #3556
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 активных строк).
|
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
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_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||||
estimate_hedonic_factor_min: float = 0.75
|
estimate_hedonic_factor_min: float = 0.75
|
||||||
estimate_hedonic_factor_max: float = 1.30
|
estimate_hedonic_factor_max: float = 1.30
|
||||||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
# ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ──
|
||||||
# Диагноз: бизнес/премиум 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"
|
|
||||||
)
|
|
||||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||||
|
|
@ -563,10 +538,6 @@ class Settings(BaseSettings):
|
||||||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||||||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||||||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
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-листинги ТОЛЬКО если в этом же
|
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
# "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)
|
# регион 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)
|
# регион 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),
|
# Ожидаемые 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: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
||||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
||||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ class DkpCorridor(BaseModel):
|
||||||
[low,high]×slack — добавляется текстовая пометка.
|
[low,high]×slack — добавляется текстовая пометка.
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
None / count=0 если по улице нет сопоставимых сделок.
|
||||||
|
|
||||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
|
||||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
"""
|
"""
|
||||||
|
|
@ -192,7 +192,7 @@ class DkpCorridor(BaseModel):
|
||||||
def advisory_only(self) -> bool:
|
def advisory_only(self) -> bool:
|
||||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||||
|
|
||||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
|
||||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
цену — зона n=3..9 на экране была неотличима от работающей.
|
||||||
|
|
@ -207,9 +207,9 @@ class DkpCorridor(BaseModel):
|
||||||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
автоматически (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):
|
class PriceTrendPoint(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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.core.db import SessionLocal, run_db_thread
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
|
|
@ -316,7 +316,7 @@ def rehydrate_search_radius_m(
|
||||||
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
||||||
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
||||||
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
# московский коридор занижен на ~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, т.е. едет итоговая цена.
|
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
||||||
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
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_MIN = 0.6
|
||||||
QUARTER_INDEX_FACTOR_MAX = 1.8
|
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`). В сырых сделках встречаются
|
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||||
|
|
@ -2056,7 +2076,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
|
||||||
Returns None если ни один город не распознан (адрес вне известного
|
Returns None если ни один город не распознан (адрес вне известного
|
||||||
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
||||||
city-фильтра. NB: коридор НЕ чисто advisory — при
|
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 влияет на итоговую цену.
|
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
||||||
"""
|
"""
|
||||||
if not address_text:
|
if not address_text:
|
||||||
|
|
@ -2266,7 +2286,7 @@ def _fetch_dkp_corridor(
|
||||||
latest_deal = dd
|
latest_deal = dd
|
||||||
ppm2_values = sorted(adjusted)
|
ppm2_values = sorted(adjusted)
|
||||||
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||||
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
|
||||||
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||||
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||||
scope = "street"
|
scope = "street"
|
||||||
|
|
@ -4067,7 +4087,7 @@ def _price_from_inputs(
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
||||||
|
|
||||||
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
||||||
slack = settings.estimate_corridor_clamp_slack
|
slack = CORRIDOR_CLAMP_SLACK
|
||||||
if dkp_raw is not None and median_ppm2 > 0:
|
if dkp_raw is not None and median_ppm2 > 0:
|
||||||
old_ppm2 = median_ppm2
|
old_ppm2 = median_ppm2
|
||||||
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
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"],
|
corridor_count=dkp_raw["count"],
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=settings.estimate_corridor_clamp_min_n,
|
min_n=CORRIDOR_CLAMP_MIN_N,
|
||||||
)
|
)
|
||||||
if clamped:
|
if clamped:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4102,9 +4122,9 @@ def _price_from_inputs(
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("low_ppm2", 0) > 0
|
and dkp_raw.get("low_ppm2", 0) > 0
|
||||||
and median_ppm2 > 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:
|
if median_ppm2 < radius_floor_ppm2:
|
||||||
floor_factor = radius_floor_ppm2 / median_ppm2
|
floor_factor = radius_floor_ppm2 / median_ppm2
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4112,7 +4132,7 @@ def _price_from_inputs(
|
||||||
" → lifting (factor=%.3f)",
|
" → lifting (factor=%.3f)",
|
||||||
int(median_ppm2),
|
int(median_ppm2),
|
||||||
dkp_raw["low_ppm2"],
|
dkp_raw["low_ppm2"],
|
||||||
settings.estimate_radius_floor_factor,
|
RADIUS_FLOOR_FACTOR,
|
||||||
int(radius_floor_ppm2),
|
int(radius_floor_ppm2),
|
||||||
floor_factor,
|
floor_factor,
|
||||||
)
|
)
|
||||||
|
|
@ -4132,7 +4152,7 @@ def _price_from_inputs(
|
||||||
|
|
||||||
if asking_to_sold_ratio is not None and median_price > 0:
|
if asking_to_sold_ratio is not None and median_price > 0:
|
||||||
effective_ratio = asking_to_sold_ratio
|
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(
|
logger.info(
|
||||||
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
||||||
effective_ratio,
|
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
|
# 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.
|
# 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).
|
# 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)
|
expected_sold_price = min(expected_sold_price, median_price)
|
||||||
if expected_sold_per_m2:
|
if expected_sold_per_m2:
|
||||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
||||||
|
|
@ -5354,7 +5374,7 @@ async def estimate_quality(
|
||||||
estimate_id = uuid4()
|
estimate_id = uuid4()
|
||||||
|
|
||||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
|
||||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
# Зона 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",
|
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||||
estimate_id,
|
estimate_id,
|
||||||
dkp_corridor.count,
|
dkp_corridor.count,
|
||||||
settings.estimate_corridor_clamp_min_n,
|
CORRIDOR_CLAMP_MIN_N,
|
||||||
(dkp_raw or {}).get("scope", "unknown"),
|
(dkp_raw or {}).get("scope", "unknown"),
|
||||||
target_city or "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]]:
|
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
||||||
|
|
||||||
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
|
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
|
||||||
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных
|
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных
|
||||||
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
||||||
тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение).
|
тянут радиусную медиану вверх.
|
||||||
"""
|
"""
|
||||||
if len(lots) < 5:
|
if len(lots) < 5:
|
||||||
return lots # на маленькой выборке нечего фильтровать
|
return lots # на маленькой выборке нечего фильтровать
|
||||||
|
|
@ -7894,11 +7914,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
tukey_k = 1.5
|
tukey_k = 1.5
|
||||||
if (
|
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
|
||||||
settings.estimate_outlier_tukey_k_small < 1.5
|
tukey_k = OUTLIER_TUKEY_K_SMALL
|
||||||
and len(prices) < settings.estimate_outlier_small_n_threshold
|
|
||||||
):
|
|
||||||
tukey_k = settings.estimate_outlier_tukey_k_small
|
|
||||||
|
|
||||||
q1 = _percentile(prices, 0.25)
|
q1 = _percentile(prices, 0.25)
|
||||||
q3 = _percentile(prices, 0.75)
|
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_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
|
||||||
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
|
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
|
||||||
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
|
"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))
|
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||||
def test_constant_keeps_prod_value(const: str) -> None:
|
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:
|
def test_settings_field_is_gone(const: str) -> None:
|
||||||
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
|
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
|
||||||
assert _PROD_VALUES[const][0] not in Settings.model_fields
|
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), а обе ценовые страховки
|
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
||||||
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||||
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор
|
||||||
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||||
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
||||||
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
||||||
|
|
@ -34,8 +34,8 @@ import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import CORRIDOR_CLAMP_MIN_N
|
||||||
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
|
from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||||
|
|
||||||
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
||||||
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
||||||
|
|
@ -46,7 +46,7 @@ _MARKER = "corridor_advisory_zone"
|
||||||
|
|
||||||
def _cap_ppm2() -> float:
|
def _cap_ppm2() -> float:
|
||||||
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
"""Потолок клампа: 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]:
|
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 схлопнулась — пороги показа и клампа сравнялись, "
|
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
||||||
"advisory-only решение #3452 больше не описывает реальность"
|
"advisory-only решение #3452 больше не описывает реальность"
|
||||||
)
|
)
|
||||||
|
|
@ -167,15 +167,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
"""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"):
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
est = _run_estimate(_corridor(n))
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
||||||
assert est.dkp_corridor.count == n
|
assert est.dkp_corridor.count == n
|
||||||
assert est.dkp_corridor.advisory_only is True, (
|
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, но порог ему не дал.
|
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
||||||
assert est.median_price_per_m2 > _cap_ppm2(), (
|
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()]
|
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
||||||
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
||||||
msg = hits[0].getMessage()
|
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
|
assert "scope=street" in msg, msg
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -193,13 +192,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
"""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"):
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
est = _run_estimate(_corridor(n))
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
assert est.dkp_corridor is not None
|
assert est.dkp_corridor is not None
|
||||||
assert est.dkp_corridor.advisory_only is False, (
|
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()), (
|
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3):
|
||||||
|
|
||||||
def _run_estimate(
|
def _run_estimate(
|
||||||
ratio_tuple: tuple[float | None, str | None],
|
ratio_tuple: tuple[float | None, str | None],
|
||||||
*,
|
|
||||||
clamp_enabled: bool = True,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
|
@ -90,7 +88,6 @@ def _run_estimate(
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
with (
|
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
|
# #2002: these tests assert the clamp/ratio math exactly. Hold the
|
||||||
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
|
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
|
||||||
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
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:
|
) -> None:
|
||||||
ratio = 1.15
|
ratio = 1.15
|
||||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
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.median_price_rub > 0, "headline должен быть задан аналогами"
|
||||||
assert est.expected_sold_price_rub is not None
|
assert est.expected_sold_price_rub is not None
|
||||||
assert est.expected_sold_price_rub <= est.median_price_rub, (
|
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:
|
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||||||
ratio = 0.8
|
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.median_price_rub > 0
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
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)
|
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:
|
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_price_rub is None
|
||||||
assert est.expected_sold_per_m2 is None
|
assert est.expected_sold_per_m2 is None
|
||||||
assert est.expected_sold_range_low_rub is None
|
assert est.expected_sold_range_low_rub is None
|
||||||
assert est.expected_sold_range_high_rub is None
|
assert est.expected_sold_range_high_rub is None
|
||||||
assert est.asking_to_sold_ratio is None
|
assert est.asking_to_sold_ratio is None
|
||||||
assert est.median_price_rub > 0
|
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);
|
* 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 neutral year term when ``target_year`` is None (≡ year 2000);
|
||||||
* the le_asking invariant — the corrected expected_sold never exceeds the asking
|
* 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
|
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||||||
requires DATABASE_URL. Set it BEFORE importing app modules.
|
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:
|
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.
|
"""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
|
ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on
|
||||||
(otherwise the re-clamp would cap it at the asking headline).
|
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: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
|
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
|
||||||
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
|
текущую подгонку (после перефита 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_b0", 0.6146)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
|
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_hedonic_year_coef", 0.1220)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", 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)
|
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)
|
factor = _expected_factor(15.0, 2025)
|
||||||
assert factor == estimator.settings.estimate_hedonic_factor_max
|
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:
|
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_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 →
|
# 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.
|
# 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)
|
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
|
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:
|
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
|
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,7 @@ def _make_payload():
|
||||||
def _run_estimate(
|
def _run_estimate(
|
||||||
analogs: list[dict[str, Any]],
|
analogs: list[dict[str, Any]],
|
||||||
dkp_raw: dict[str, Any] | None,
|
dkp_raw: dict[str, Any] | None,
|
||||||
*,
|
|
||||||
radius_floor_factor: float = 0.8,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -107,7 +104,6 @@ def _run_estimate(
|
||||||
),
|
),
|
||||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
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)
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
|
@ -132,7 +128,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
"high_ppm2": 220_000,
|
"high_ppm2": 220_000,
|
||||||
"period_months": 12,
|
"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
|
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||||
assert est.median_price_per_m2 >= floor_ppm2, (
|
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,
|
"high_ppm2": 220_000,
|
||||||
"period_months": 12,
|
"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, медиана не изменяется
|
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
|
||||||
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
# Медиана должна остаться в диапазоне аналогов (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=80_000.0),
|
||||||
_make_listing(price_per_m2=85_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 не поднимает
|
# median ~80k, без dkp_raw floor не поднимает
|
||||||
assert est.median_price_per_m2 < 100_000, (
|
assert est.median_price_per_m2 < 100_000, (
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function dealsAsOfLabel(
|
||||||
/**
|
/**
|
||||||
* #3452: подпись под коридором ДКП, собранным из малого числа сделок.
|
* #3452: подпись под коридором ДКП, собранным из малого числа сделок.
|
||||||
*
|
*
|
||||||
* Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый
|
* Порог (`CORRIDOR_CLAMP_MIN_N`) считает бэкенд и отдаёт готовый
|
||||||
* advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки —
|
* advisory_only: ниже него у коридора выключены ОБЕ его ценовые страховки —
|
||||||
* кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы
|
* кламп headline сверху и radius-floor снизу. Молчание тут выдавало бы
|
||||||
* выключенную страховку за работающую: на экране такой коридор ничем не
|
* выключенную страховку за работающую: на экране такой коридор ничем не
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ export interface DkpCorridor {
|
||||||
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
||||||
latest_deal_date?: string | null;
|
latest_deal_date?: string | null;
|
||||||
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
||||||
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
|
// CORRIDOR_CLAMP_MIN_N) → коридор показан, но цену по нему НЕ
|
||||||
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
||||||
// коридора не про это — при count >= порога он в цену как раз идёт.
|
// коридора не про это — при count >= порога он в цену как раз идёт.
|
||||||
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue