gendesign/tradein-mvp/backend/tests/test_estimator_price_spine.py
bot-backend 42b0ebe338
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
fix(tradein/estimate): залипший anchor_tier молча глушил IMV-blend и quarter-index
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.

Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).

- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
  после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
  display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
  (иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
  отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
  в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
  уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
  расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
  = False внутри replay_fixture (было только в самом гейте) — иначе штатная
  регенерация baseline (--from-fixture --update-baseline) писала baseline,
  который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.

Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
2026-08-17 10:54:07 +03:00

576 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Hermetic unit tests for _price_from_inputs (#1966).
Calls the pure synchronous pricing function directly with stub callables and
hand-built inputs — no DB, no network, no mocks. Verifies that the extraction
preserved the pricing logic identically to the original block in estimate_quality.
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules.
"""
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.geocoder import GeocodeResult
# ── helpers ──────────────────────────────────────────────────────────────────
def _geo(coarse: bool = False) -> GeocodeResult:
"""Minimal GeocodeResult for test injection."""
full_address = "Екатеринбург" if coarse else "ул. Тестовая, 1"
return GeocodeResult(
lat=56.838,
lon=60.597,
full_address=full_address,
provider="nominatim",
confidence="approximate",
)
def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
return {"price_per_m2": ppm2, "address": address, "source": source}
def _lots(ppm2: float, n: int = 7) -> list[dict]:
"""n unique-address lots all at the same ppm2."""
return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)]
def _dkp_raw(
low: int = 80_000,
median: int = 120_000,
high: int = 150_000,
count: int = 20,
period_months: int = 12,
) -> dict:
return {
"low_ppm2": low,
"median_ppm2": median,
"high_ppm2": high,
"count": count,
"period_months": period_months,
}
def _anchor_comp(ppm2: float, area: float = 50.0, rooms: int = 2) -> dict:
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
# Stub callables — returned in each test via closure.
def _ratio_stub(
ratio: float | None,
basis: str | None = "per_rooms",
) -> "tuple[float | None, str | None]":
return ratio, basis if ratio is not None else None
def _qi_stub_none(q: str) -> "tuple[float, int] | None":
return None
def _qis_stub_empty(qs: list[str]) -> dict[str, float]:
return {}
def _call(
*,
listings: list[dict] | None = None,
area_m2: float = 50.0,
rooms: int | None = 2,
repair_state: str | None = None,
floor: int | None = 5,
total_floors: int | None = 10,
target_year: int | None = None,
analog_tier: str = "W",
fallback_used: bool = False,
area_widened: bool = False,
anchor_comps: list[dict] | None = None,
anchor_tier_fetched: str | None = None,
dkp_raw: dict | None = None,
imv_anchor: dict | None = None,
imv_eval=None,
yandex_val_present: bool = False,
cian_val_present: bool = False,
ratio: float | None = None,
quarter_index_lookup=None,
quarter_indexes_lookup=None,
target_house_cadnum: str | None = None,
dadata_coarse: bool = False,
geo: GeocodeResult | None = None,
dadata_qc_geo: int | None = None,
) -> estimator.PricingResult:
if listings is None:
listings = _lots(100_000)
if anchor_comps is None:
anchor_comps = []
if geo is None:
geo = _geo(coarse=dadata_coarse)
if quarter_index_lookup is None:
quarter_index_lookup = _qi_stub_none
if quarter_indexes_lookup is None:
quarter_indexes_lookup = _qis_stub_empty
_ratio = ratio
_basis = "per_rooms" if ratio is not None else None
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return _ratio, _basis if _ratio is not None else None
return estimator._price_from_inputs(
listings=listings,
area_m2=area_m2,
rooms=rooms,
repair_state=repair_state,
floor=floor,
total_floors=total_floors,
target_year=target_year,
analog_tier=analog_tier,
fallback_used=fallback_used,
area_widened=area_widened,
anchor_comps=anchor_comps,
anchor_tier_fetched=anchor_tier_fetched,
dkp_raw=dkp_raw,
imv_anchor=imv_anchor,
imv_eval=imv_eval,
yandex_val_present=yandex_val_present,
cian_val_present=cian_val_present,
ratio_resolver=ratio_resolver,
quarter_index_lookup=quarter_index_lookup,
quarter_indexes_lookup=quarter_indexes_lookup,
target_house_cadnum=target_house_cadnum,
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata_qc_geo,
)
# ── Tests ────────────────────────────────────────────────────────────────────
def test_radius_only_median_and_expected_sold(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pure radius path: 7 uniform lots → correct median, n_analogs, expected_sold."""
# #2002: this asserts the ratio mechanism (expected_sold == headline × ratio).
# Hold the orthogonal hedonic correction OFF (OFF ⇒ exact legacy expected_sold).
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
pr = _call(listings=_lots(100_000, n=7), ratio=0.95)
assert pr.median_price == int(100_000 * 50.0) # 5_000_000
assert pr.median_ppm2 == 100_000.0
assert pr.n_analogs == 7
assert pr.anchor_tier is None
assert pr.dkp_corridor is None
assert pr.asking_to_sold_ratio == 0.95
assert pr.expected_sold_price == round(5_000_000 * 0.95) # 4_750_000
assert pr.expected_sold_per_m2 == round(100_000 * 0.95) # 95_000
assert "avito" in pr.sources_used_pre
assert len(pr.listings_clean) == 7
def test_same_building_anchor_tier_a_mutates_headline() -> None:
"""Tier A same-building anchor replaces radius median with higher price.
5 radius lots at 100k ppm2 (5M total). 5 anchor comps at 200k ppm2 (10M total).
After anchor fires: median_price >> radius median, n_analogs == anchor count.
"""
comps = [_anchor_comp(200_000) for _ in range(5)]
pr = _call(
listings=_lots(100_000, n=5),
anchor_comps=comps,
anchor_tier_fetched="A",
ratio=None,
)
# Anchor must have fired (not suppressed).
assert pr.anchor_tier == "A"
# Headline is anchor-derived — must be above radius median (5_000_000).
assert pr.median_price > 5_000_000
# n_analogs resets to anchor population.
assert pr.n_analogs == 5
# anchor_comps_used is the injected comps list.
assert len(pr.anchor_comps_used) == 5
# No ratio → expected_sold is None.
assert pr.expected_sold_price is None
def test_tier_c_corridor_gate_suppresses_anchor() -> None:
"""Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed.
#2661: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None,
но оставлял залипший "C" — и этот флаг молча глушил IMV-blend/quarter-index/
radius-floor, будто headline построил якорь). Headline как и раньше остаётся
радиусной медианой.
"""
# 5 comps at 300k ppm2; corridor_high=150k; gate threshold=150k×1.5=225k.
# 300k > 225k → suppressed.
comps = [_anchor_comp(300_000) for _ in range(5)]
radius_median_price = int(100_000 * 50.0)
pr = _call(
listings=_lots(100_000, n=5),
anchor_comps=comps,
anchor_tier_fetched="C",
dkp_raw=_dkp_raw(high=150_000, count=15),
ratio=None,
)
# #2661: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним.
assert pr.anchor_tier is None
# Headline was NOT mutated by the suppressed anchor — stays at radius median.
assert pr.median_price == radius_median_price
# anchor_comps_used stays empty (anchor didn't fire).
assert pr.anchor_comps_used == []
def test_low_conf_gate_suppresses_anchor() -> None:
"""Low-confidence anchor is suppressed; anchor_tier reset to None.
4 comps with wide spread → high cv → fsd > 0.20 → confidence='low' → suppressed.
"""
# 4 comps at [100k, 200k, 300k, 400k] → cv≈0.45, fsd≈0.20 → "low".
comps = [_anchor_comp(p) for p in [100_000, 200_000, 300_000, 400_000]]
radius_median_price = int(100_000 * 50.0)
pr = _call(
listings=_lots(100_000, n=5),
anchor_comps=comps,
anchor_tier_fetched="A", # starts as A, gate resets to None
ratio=None,
)
# Gate resets anchor_tier to None on suppression.
assert pr.anchor_tier is None
# Headline stays at radius median.
assert pr.median_price == radius_median_price
assert pr.anchor_comps_used == []
def test_anchor_tier_reset_when_anchor_not_built() -> None:
"""Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C".
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
дальше по коду он читается как «headline построил якорь».
"""
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4
radius_median_price = int(100_000 * 50.0)
pr = _call(
listings=_lots(100_000, n=5),
anchor_comps=comps,
anchor_tier_fetched="C",
ratio=None,
)
assert pr.anchor_tier is None
assert pr.anchor_comps_used == []
assert pr.median_price == radius_median_price # headline остался радиусным
def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None:
"""Недостроенный якорь (тир "C" не подтверждён) + IMV-якорь — blend обязан сработать.
Денежное последствие залипшего флага #2661: сделки, у которых якоря нет ни до, ни
после правки, всё равно теряли IMV-blend — залипший ``anchor_tier="C"`` глушил гейт
``anchor_tier is None`` у блока #651. Числа те же, что в
``test_imv_blend_raises_median_when_anchor_tier_none`` выше (radius median=5M,
IMV=7M → blend 6M), плюс недостроенный якорь, который раньше эту цепочку выключал.
"""
comps = [_anchor_comp(150_000), _anchor_comp(155_000)]
imv_anchor = {
"recommended_price": 7_000_000,
"lower_price": 6_000_000,
"higher_price": 8_000_000,
"market_count": 50,
}
pr = _call(
listings=_lots(100_000, n=5),
anchor_comps=comps,
anchor_tier_fetched="C",
imv_anchor=imv_anchor,
ratio=None,
)
assert pr.anchor_tier is None
assert pr.median_price == round(5_000_000 * 0.5 + 7_000_000 * 0.5) # IMV-blend сработал
assert pr.avito_imv_summary is not None
def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None:
"""Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен».
#2661: раньше карточку заполнял display-only блок под условием `anchor_tier is not
None`. После сброса залипшего флага открылась щель: blend не срабатывает (нужен
listings_clean и median_price > 0), старый display-only блок тоже не срабатывал (tier
уже None) — пользователь ТЕРЯЛ карточку, которую видел раньше. Гейт по tier у
display-only блока снят — отображение, не деньги (median/expected_sold не трогает).
"""
comps = [_anchor_comp(150_000), _anchor_comp(155_000)]
imv_anchor = {
"recommended_price": 7_000_000,
"lower_price": 6_000_000,
"higher_price": 8_000_000,
"market_count": 50,
}
pr = _call(
listings=[],
anchor_comps=comps,
anchor_tier_fetched="C",
imv_anchor=imv_anchor,
ratio=None,
)
assert pr.anchor_tier is None
assert pr.median_price == 0 # headline подавлен гейтом достаточности (нет листингов)
assert pr.avito_imv_summary is not None, "карточка IMV потеряна"
assert pr.avito_imv_summary.recommended_price == 7_000_000
def test_imv_blend_raises_median_when_anchor_tier_none() -> None:
"""IMV blend pushes radius median up when IMV >> median × threshold.
radius median=5M, IMV recommended=7M, area=50, weight=0.5, threshold=1.15.
IMV/radius = 7M/5M = 1.4 > 1.15 → blend: new_median = round(5M×0.5 + 7M×0.5).
"""
imv_anchor = {
"recommended_price": 7_000_000,
"lower_price": 6_000_000,
"higher_price": 8_000_000,
"market_count": 50,
}
pr = _call(
listings=_lots(100_000, n=5),
imv_anchor=imv_anchor,
ratio=None,
)
expected_median = round(5_000_000 * 0.5 + 7_000_000 * 0.5) # 6_000_000
assert pr.median_price == expected_median
assert pr.range_high == 8_000_000 # from anchor_higher
assert pr.avito_imv_summary is not None
assert pr.avito_imv_summary.recommended_price == 7_000_000
assert "avito_imv" in pr.sources_used_pre
def test_quarter_index_guard2_skip_when_all_analogs_in_target_quarter() -> None:
"""Guard-2: when all analogs are in the target quarter, index is NOT applied.
same_quarter_ratio=1.0 > skip_ratio=0.6 → Guard-2 fires → median unchanged.
"""
target_cadnum = "66:41:0204016:350"
target_quarter = "66:41:0204016"
# Analogs are all in the SAME quarter as the target.
lots = [
{
"price_per_m2": 100_000,
"address": f"ул. Тестовая, {i + 1}",
"source": "avito",
"building_cadastral_number": f"{target_quarter}:{100 + i}",
}
for i in range(5)
]
qi_called: list[str] = []
def qi_lookup(q: str) -> tuple[float, int] | None:
qi_called.append(q)
return (1.5, 100) if q == target_quarter else None # high index — would change price
pr = _call(
listings=lots,
target_house_cadnum=target_cadnum,
quarter_index_lookup=qi_lookup,
quarter_indexes_lookup=_qis_stub_empty,
ratio=None,
)
# Guard-2 fired: median must remain unchanged (5M, not ×1.5).
assert pr.median_price == int(100_000 * 50.0)
# quarter_index was looked up but did NOT add "quarter_index" to sources.
assert "quarter_index" not in pr.sources_used_pre
def test_quarter_index_applied_when_analogs_in_different_quarter() -> None:
"""Quarter-index IS applied when analogs are in a different quarter from target.
target_qi=1.2, avg_analog_qi=1.0 → factor=1.2 → median_price×1.2.
Guard-2 skips (same_quarter_ratio=0.0 < 0.6).
"""
target_cadnum = "66:41:0204016:350"
target_quarter = "66:41:0204016"
analog_quarter = "66:41:0999999"
lots = [
{
"price_per_m2": 100_000,
"address": f"ул. Иная, {i + 1}",
"source": "avito",
"building_cadastral_number": f"{analog_quarter}:{i + 1}",
}
for i in range(5)
]
def qi_lookup(q: str) -> tuple[float, int] | None:
return (1.2, 100) if q == target_quarter else None
def qis_lookup(qs: list[str]) -> dict[str, float]:
return {q: 1.0 for q in qs if q == analog_quarter}
pr = _call(
listings=lots,
target_house_cadnum=target_cadnum,
quarter_index_lookup=qi_lookup,
quarter_indexes_lookup=qis_lookup,
ratio=None,
)
# factor=1.2/1.0=1.2; original radius=5M → adjusted=6M (±rounding via _apply_quarter_index)
assert pr.median_price > int(100_000 * 50.0) # index pushed price up
assert "quarter_index" in pr.sources_used_pre
def test_corridor_soft_clamp_headline_above_cap() -> None:
"""Headline above corridor_high × (1+slack) is clamped down.
radius lots at 250k ppm2. corridor_high=150k, slack=0.40 →
cap=150k×1.40=210k. 250k > 210k → clamped to 210k.
"""
pr = _call(
listings=_lots(250_000, n=7),
dkp_raw=_dkp_raw(low=80_000, median=120_000, high=150_000, count=15),
ratio=None,
)
# cap = 150_000 × 1.40 = 210_000
# Clamped: new ppm2 == 210_000, new_price = round(210_000 × 50)
assert pr.median_ppm2 == pytest.approx(210_000.0, rel=1e-4)
assert pr.median_price == round(210_000 * 50.0)
# DKP corridor present in result.
assert pr.dkp_corridor is not None
assert pr.dkp_corridor.high_ppm2 == 150_000
def test_expected_sold_from_ratio_and_none_when_ratio_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""expected_sold = headline × ratio; when ratio is None, all expected_sold fields None."""
# #2002: ratio-mechanism test — hold the orthogonal hedonic correction OFF
# so expected_sold == headline × ratio exactly (OFF ⇒ legacy behavior).
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
# Case A: ratio=0.90 → expected_sold fields filled.
pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90)
assert pr_ratio.asking_to_sold_ratio == 0.90
assert pr_ratio.expected_sold_price is not None
assert pr_ratio.expected_sold_price == round(pr_ratio.median_price * 0.90)
assert pr_ratio.expected_sold_per_m2 is not None
assert pr_ratio.expected_sold_range_low is not None
assert pr_ratio.expected_sold_range_high is not None
# Case B: ratio=None → all expected_sold fields None.
pr_none = _call(listings=_lots(100_000, n=5), ratio=None)
assert pr_none.asking_to_sold_ratio is None
assert pr_none.ratio_basis is None
assert pr_none.expected_sold_price is None
assert pr_none.expected_sold_per_m2 is None
assert pr_none.expected_sold_range_low is None
assert pr_none.expected_sold_range_high is None
def test_coarse_geo_downgrades_confidence_to_low() -> None:
"""dadata_coarse=True with qc_geo=2 → confidence='low' with settlement label."""
pr = _call(
listings=_lots(100_000, n=7),
dadata_coarse=True,
dadata_qc_geo=2,
ratio=None,
# No anchor, no IMV → radius path → anchor_tier is None (not "A" → downgrade applies)
geo=_geo(coarse=False), # geo itself not coarse; using dadata_coarse signal
)
assert pr.confidence == "low"
assert "населённого пункта" in pr.explanation
# ── #2141: честный asking_to_sold_ratio (дескриптор бейджа «N% к рынку») ──────
def test_honest_ratio_invariant_with_hedonic_uplift(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2141: hedonic-uplift (#2002) поднимает expected_sold выше median×raw_ratio →
сохранённый asking_to_sold_ratio пересчитывается в ЧЕСТНЫЙ expected_sold/median,
но сам expected_sold (выкуп) НЕ падает до наивного median×raw. Это ровно тот
live-дефект, где бейдж показывал «23%» при фактических «5%»."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
raw_ratio = 0.74
# Малая площадь + новостройка → hedonic factor у верхней границы (≈1.28).
pr = _call(
listings=_lots(200_000, n=7),
area_m2=25.0,
rooms=1,
target_year=2024,
ratio=raw_ratio,
)
assert pr.expected_sold_price is not None
assert pr.asking_to_sold_ratio is not None
assert pr.median_price > 0
# Выкуп НЕ упал до наивного median×raw_ratio — hedonic-uplift сохранён.
assert pr.expected_sold_price > round(pr.median_price * raw_ratio)
# Инвариант: сохранённый ratio == фактическому expected_sold/median (бейдж честный).
assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3
# Пересчёт действительно сработал (ratio уже не сырой табличный).
assert pr.asking_to_sold_ratio > raw_ratio + 1e-3
def test_honest_ratio_invariant_under_corridor_clamp(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2141: даже когда corridor-clamp прижимает headline ВНИЗ к ДКП-коридору,
сохранённый asking_to_sold_ratio == expected_sold/median на ФИНАЛЬНЫХ значениях."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
pr = _call(
listings=_lots(250_000, n=7),
area_m2=50.0,
rooms=2,
target_year=2024,
dkp_raw=_dkp_raw(low=80_000, median=120_000, high=150_000, count=15),
ratio=0.80,
)
assert pr.expected_sold_price is not None
assert pr.asking_to_sold_ratio is not None
# corridor-clamp прижал headline (250k > cap=150k×1.40=210k).
assert pr.median_ppm2 < 250_000
# Инвариант держится на финальных значениях (после clamp + hedonic).
assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3
def test_honest_ratio_byte_identical_without_shift(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2141 regression: hedonic OFF + без клампа → expected == median×ratio, и
сохранённый ratio остаётся СЫРЫМ табличным байт-в-байт (дескриптор не пересчитан)."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
pr = _call(listings=_lots(100_000, n=7), area_m2=50.0, rooms=2, ratio=0.90)
assert pr.asking_to_sold_ratio == 0.90 # байт-в-байт сырой табличный ratio
assert pr.expected_sold_price == round(pr.median_price * 0.90)
assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3
def test_honest_ratio_invariant_under_segment_multiplier(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2087 M2 regression: донат-виджет («N% к рынку») считается фронтом как
(asking_to_sold_ratio1)×100 — рисуется мимо показанных median/expected_sold, если
сохранённый ratio разошёлся с фактическим expected_sold_price/median_price. #2255
segment-multiplier — ЕДИНСТВЕННАЯ ценовая мутация ПОСЛЕ honest_ratio-пересчёта
(#2141): множит median_price И expected_sold_price ОДНИМ и тем же factor, но
round() на каждой стороне — независимый. Проверяем, что инвариант держится и
здесь (с точностью до шума округления), а не только до мутации."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
monkeypatch.setattr(
estimator.settings,
"estimate_segment_multipliers",
{"эконом": 1.00, "комфорт": 1.00, "бизнес": 1.08, "элит": 1.06},
)
# 213_777 ₽/м² falls in the "бизнес" band (160k220k) → factor 1.08 fires.
pr = _call(listings=_lots(213_777.0, n=7), area_m2=53.4, rooms=2, ratio=0.83)
assert "segment_multiplier" in (pr.sources_used_pre or []) # confirm the mult fired
assert pr.expected_sold_price is not None and pr.median_price > 0
assert pr.asking_to_sold_ratio == pytest.approx(
pr.expected_sold_price / pr.median_price, abs=1e-3
)