fix(tradein): завершить thin-market гейт IMV — Guard-1b и GET-путь (#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 5m23s

MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком
рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку
«потому что якорь есть»: headline не получал ни одной поправки, отброшенный
якорь двигал деньги вычитанием. Теперь present = not thin_market.

MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка
сборки карточки: market_count=0 читался как «неизвестно», thin_market не
передавался вовсе → одна оценка показывала thin_market=True в POST и False
при переоткрытии.
This commit is contained in:
bot-backend 2026-09-02 17:44:32 +05:00
parent 148da3e5a7
commit 4f4345e27c
4 changed files with 90 additions and 3 deletions

View file

@ -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

View file

@ -3422,7 +3422,13 @@ def _price_from_inputs(
)
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,

View file

@ -110,6 +110,24 @@ def test_blend_thick_market_unchanged_regression_3323() -> None:
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

View file

@ -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).