Merge pull request 'fix(tradein/estimator): thin-market гейт IMV перестаёт быть витринным — рынок из 1 объявления больше не двигает headline и range_high' (#3336) from fix/3323-imv-thin-market-gate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m38s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m38s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
This commit is contained in:
commit
87781ad53c
4 changed files with 203 additions and 8 deletions
|
|
@ -776,7 +776,16 @@ def load_estimate(
|
|||
recommended_price=int(imv_raw["recommended_price"]),
|
||||
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
|
||||
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
|
||||
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
|
||||
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
|
||||
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
|
||||
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
|
||||
market_count=(
|
||||
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
|
||||
),
|
||||
thin_market=(
|
||||
imv_raw.get("market_count") is not None
|
||||
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
|
||||
),
|
||||
)
|
||||
if imv_raw is not None and imv_raw.get("recommended_price")
|
||||
else None
|
||||
|
|
|
|||
|
|
@ -1320,6 +1320,8 @@ def _apply_imv_blend(
|
|||
anchor_higher: int | None,
|
||||
weight: float,
|
||||
threshold: float,
|
||||
market_count: int | None = None,
|
||||
thin_market_threshold: int = 0,
|
||||
) -> tuple[int, int, float, bool, int | None]:
|
||||
"""Чистая (testable без БД) blend-трансформация для #651.
|
||||
|
||||
|
|
@ -1330,12 +1332,22 @@ def _apply_imv_blend(
|
|||
Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы
|
||||
включить A (информативность). Null-guard: при anchor_total=None — no-op.
|
||||
|
||||
#3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь
|
||||
статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend
|
||||
медианы, ни расширение range_high. Гейт стоит здесь — в единственной точке,
|
||||
через которую IMV влияет на деньги, а не в ветках построения якоря.
|
||||
`thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен →
|
||||
поведение прежнее.
|
||||
|
||||
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
|
||||
anchor_used_total).
|
||||
"""
|
||||
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
|
||||
return median_price, range_high, median_ppm2, False, None
|
||||
|
||||
if market_count is not None and market_count < thin_market_threshold:
|
||||
return median_price, range_high, median_ppm2, False, None
|
||||
|
||||
blended = False
|
||||
new_median = median_price
|
||||
new_ppm2 = median_ppm2
|
||||
|
|
@ -3351,7 +3363,12 @@ def _price_from_inputs(
|
|||
# (HeroSummary), название площадки туда не должно утекать —
|
||||
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
|
||||
anchor_label = "оценочной модели площадки"
|
||||
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
||||
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
|
||||
_imv_mc = (
|
||||
int(imv_anchor["market_count"])
|
||||
if imv_anchor.get("market_count") is not None
|
||||
else None
|
||||
)
|
||||
avito_imv_summary = AvitoImvSummary(
|
||||
recommended_price=anchor_total,
|
||||
lower_price=(
|
||||
|
|
@ -3397,17 +3414,27 @@ def _price_from_inputs(
|
|||
),
|
||||
)
|
||||
|
||||
# #audit-5b: thin-market warning.
|
||||
# #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning,
|
||||
# а IMV всё равно двигал headline и растягивал range_high — теперь гейт в
|
||||
# `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это.
|
||||
if avito_imv_summary is not None and avito_imv_summary.thin_market:
|
||||
logger.warning(
|
||||
"avito_imv thin_market #audit-5b: market_count=%s"
|
||||
" (< avito_imv_thin_market_threshold=%d) — IMV reliability low",
|
||||
"avito_imv thin_market #3323: market_count=%s"
|
||||
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
|
||||
" пути (ни blend медианы, ни расширение range_high); остаётся"
|
||||
" display-only в карточке avito_imv",
|
||||
avito_imv_summary.market_count,
|
||||
settings.avito_imv_thin_market_threshold,
|
||||
)
|
||||
|
||||
if anchor_total is not None:
|
||||
imv_anchor_present = True
|
||||
# #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b
|
||||
# (#764, quarter-index) не должен глушить поправку «потому что якорь есть» —
|
||||
# иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет
|
||||
# деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками.
|
||||
imv_anchor_present = (
|
||||
not avito_imv_summary.thin_market if avito_imv_summary is not None else True
|
||||
)
|
||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
||||
median_price=median_price,
|
||||
range_high=range_high,
|
||||
|
|
@ -3417,6 +3444,10 @@ def _price_from_inputs(
|
|||
anchor_higher=anchor_higher,
|
||||
weight=settings.estimate_imv_blend_weight,
|
||||
threshold=settings.estimate_imv_blend_threshold,
|
||||
market_count=(
|
||||
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||
),
|
||||
thin_market_threshold=settings.avito_imv_thin_market_threshold,
|
||||
)
|
||||
if blended:
|
||||
logger.info(
|
||||
|
|
@ -3448,7 +3479,11 @@ def _price_from_inputs(
|
|||
# median/expected_sold/ranges блок не трогает.
|
||||
if avito_imv_summary is None:
|
||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||
_disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
||||
_disp_mc = (
|
||||
int(imv_anchor["market_count"])
|
||||
if imv_anchor.get("market_count") is not None
|
||||
else None
|
||||
)
|
||||
avito_imv_summary = AvitoImvSummary(
|
||||
recommended_price=int(imv_anchor["recommended_price"]),
|
||||
lower_price=(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,103 @@ def test_blend_premium_raises_median_and_extends_range() -> None:
|
|||
assert anchor_used == 100_000_000
|
||||
|
||||
|
||||
def test_blend_thin_market_gate_drops_imv_from_money_path_3323() -> None:
|
||||
"""#3323: market_count=1 → IMV не двигает ни headline, ни range_high.
|
||||
|
||||
Те же числа, что в test_blend_premium_raises_median_and_extends_range
|
||||
(там 50М → 75М, range 60М → 110М). С тонким рынком ждём ЧИСТЫЙ расчёт:
|
||||
медиана 50М и range_high 60М — без вклада IMV.
|
||||
"""
|
||||
area = 80.0
|
||||
median_price = 50_000_000
|
||||
range_high = 60_000_000
|
||||
median_ppm2 = median_price / area
|
||||
|
||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
||||
median_price=median_price,
|
||||
range_high=range_high,
|
||||
median_ppm2=median_ppm2,
|
||||
area=area,
|
||||
anchor_total=100_000_000,
|
||||
anchor_higher=110_000_000,
|
||||
weight=0.5,
|
||||
threshold=1.15,
|
||||
market_count=1,
|
||||
thin_market_threshold=10,
|
||||
)
|
||||
|
||||
assert blended is False
|
||||
assert new_median == 50_000_000
|
||||
assert new_range_high == 60_000_000
|
||||
assert new_ppm2 == median_ppm2
|
||||
assert anchor_used is None
|
||||
|
||||
|
||||
def test_blend_thick_market_unchanged_regression_3323() -> None:
|
||||
"""#3323 регрессия: market_count >= threshold → числа как до гейта."""
|
||||
area = 80.0
|
||||
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
|
||||
median_price=50_000_000,
|
||||
range_high=60_000_000,
|
||||
median_ppm2=50_000_000 / area,
|
||||
area=area,
|
||||
anchor_total=100_000_000,
|
||||
anchor_higher=110_000_000,
|
||||
weight=0.5,
|
||||
threshold=1.15,
|
||||
market_count=10,
|
||||
thin_market_threshold=10,
|
||||
)
|
||||
|
||||
assert blended is True
|
||||
assert new_median == 75_000_000
|
||||
assert new_range_high == 110_000_000
|
||||
assert new_ppm2 == 75_000_000 / area
|
||||
assert anchor_used == 100_000_000
|
||||
|
||||
|
||||
def test_blend_market_count_none_passes_gate_3323() -> None:
|
||||
"""#3323 контракт: market_count неизвестен (None) → гейт не срабатывает."""
|
||||
new_median, _, _, blended, _ = _apply_imv_blend(
|
||||
median_price=50_000_000,
|
||||
range_high=60_000_000,
|
||||
median_ppm2=625_000.0,
|
||||
area=80.0,
|
||||
anchor_total=100_000_000,
|
||||
anchor_higher=110_000_000,
|
||||
weight=0.5,
|
||||
threshold=1.15,
|
||||
market_count=None,
|
||||
thin_market_threshold=10,
|
||||
)
|
||||
assert blended is True
|
||||
assert new_median == 75_000_000
|
||||
|
||||
|
||||
def test_blend_thin_market_gate_boundary_3323() -> None:
|
||||
"""#3323 граница: threshold-1 → гейт закрыт, threshold → открыт."""
|
||||
area = 80.0
|
||||
kwargs: dict[str, Any] = {
|
||||
"median_price": 50_000_000,
|
||||
"range_high": 60_000_000,
|
||||
"median_ppm2": 50_000_000 / area,
|
||||
"area": area,
|
||||
"anchor_total": 100_000_000,
|
||||
"anchor_higher": 110_000_000,
|
||||
"weight": 0.5,
|
||||
"threshold": 1.15,
|
||||
"thin_market_threshold": 10,
|
||||
}
|
||||
|
||||
gated = _apply_imv_blend(**kwargs, market_count=9)
|
||||
assert gated[:2] == (50_000_000, 60_000_000)
|
||||
assert gated[3] is False
|
||||
|
||||
passed = _apply_imv_blend(**kwargs, market_count=10)
|
||||
assert passed[:2] == (75_000_000, 110_000_000)
|
||||
assert passed[3] is True
|
||||
|
||||
|
||||
def test_blend_no_op_when_anchor_below_median() -> None:
|
||||
"""A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться."""
|
||||
area = 50.0
|
||||
|
|
|
|||
|
|
@ -388,6 +388,7 @@ def _run_estimate_qi(
|
|||
*,
|
||||
anchor_tier_override: str | None = None,
|
||||
analog_indexes: dict[str, float] | None = None,
|
||||
imv_anchor: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
||||
|
||||
|
|
@ -446,7 +447,7 @@ def _run_estimate_qi(
|
|||
"app.services.estimator._get_asking_sold_ratio",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
|
||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=imv_anchor),
|
||||
# Stub singular target-quarter lookup
|
||||
patch(
|
||||
"app.services.estimator._lookup_quarter_index",
|
||||
|
|
@ -881,6 +882,59 @@ def test_guard1b_imv_blend_prevents_correction() -> None:
|
|||
assert "квартал" not in (est.confidence_explanation or "").lower()
|
||||
|
||||
|
||||
def test_guard1b_thin_market_imv_does_not_block_quarter_index_3323() -> None:
|
||||
"""#3323: тонкий рынок → якорь отброшен, значит Guard-1b НЕ глушит поправку.
|
||||
|
||||
Тот же вход, что в test_guard1b_imv_blend_prevents_correction (anchor 30М ≫
|
||||
медианы 6М), но market_count=1 < порога 10. Ждём по значению: blend не
|
||||
сработал (не 18М) и квартальная поправка ПРИМЕНИЛАСЬ → 6М × 1.2.
|
||||
"""
|
||||
base_median = round(_BASE_PPM2 * _AREA) # 6_000_000
|
||||
thin_anchor = {
|
||||
"recommended_price": 30_000_000,
|
||||
"lower_price": 25_000_000,
|
||||
"higher_price": 35_000_000,
|
||||
"market_count": 1, # < settings.avito_imv_thin_market_threshold (10)
|
||||
"rooms": 1,
|
||||
"area_m2": _AREA,
|
||||
}
|
||||
|
||||
est = _run_estimate_qi(
|
||||
analogs=_ANALOGS_OTHER_QUARTER,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||
imv_anchor=thin_anchor,
|
||||
)
|
||||
|
||||
assert est.median_price_rub == round(base_median * 1.2) # 7_200_000
|
||||
assert est.median_price_rub != round(6_000_000 * 0.5 + 30_000_000 * 0.5) # не blend
|
||||
assert "квартал" in (est.confidence_explanation or "").lower()
|
||||
|
||||
|
||||
def test_guard1b_thick_market_imv_still_blocks_quarter_index_3323() -> None:
|
||||
"""#3323 регрессия: market_count >= порога → blend как раньше, поправка подавлена."""
|
||||
thick_anchor = {
|
||||
"recommended_price": 30_000_000,
|
||||
"lower_price": 25_000_000,
|
||||
"higher_price": 35_000_000,
|
||||
"market_count": 500,
|
||||
"rooms": 1,
|
||||
"area_m2": _AREA,
|
||||
}
|
||||
|
||||
est = _run_estimate_qi(
|
||||
analogs=_ANALOGS_OTHER_QUARTER,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||
imv_anchor=thick_anchor,
|
||||
)
|
||||
|
||||
assert est.median_price_rub == round(6_000_000 * 0.5 + 30_000_000 * 0.5) # 18_000_000
|
||||
assert "квартал" not in (est.confidence_explanation or "").lower()
|
||||
|
||||
|
||||
def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None:
|
||||
"""Guard-1b: IMV anchor присутствует но ниже blend-порога (blended=False).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue