fix(tradein): thin-market IMV не двигает деньги (#3323)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-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 / backend-tests (pull_request) Successful in 5m6s

avito_imv_thin_market_threshold рождал только warning: IMV с market_count=1
всё равно уходил в blend (w=0.5 при A > median*1.15) и растягивал range_high.
Гейт поставлен в _apply_imv_blend — единственной точке, через которую IMV
влияет на деньги (обе ветки якоря, imv_anchor и imv_eval, сходятся там):
market_count < threshold → no-op, якорь остаётся display-only в карточке.
market_count >= threshold и market_count=None (порог не передан) — поведение
прежнее. market_count=0 больше не читается как «неизвестно».
Warning теперь говорит, что IMV ОТБРОШЕН, а не просто «тонкий рынок».
This commit is contained in:
bot-backend 2026-09-02 17:36:22 +05:00
parent d8b7de2cf6
commit 148da3e5a7
2 changed files with 113 additions and 5 deletions

View file

@ -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
@ -3348,7 +3360,12 @@ def _price_from_inputs(
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
anchor_label = "оценке Avito IMV"
_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=(
@ -3391,11 +3408,15 @@ 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,
)
@ -3411,6 +3432,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(
@ -3442,7 +3467,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=(

View file

@ -55,6 +55,85 @@ 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_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