From 148da3e5a79068a4185df291db64421452bb1471 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:36:22 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20thin-market=20IMV=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D0=B5=D1=82=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ОТБРОШЕН, а не просто «тонкий рынок». --- tradein-mvp/backend/app/services/estimator.py | 39 +++++++-- .../backend/tests/test_estimator_imv_blend.py | 79 +++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..1a5fae76 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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=( diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 77bf6a57..024bbf5e 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -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 -- 2.45.3 From 4f4345e27c2aeb7038c14b1ac51cd38f535a4d9e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:44:32 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein):=20=D0=B7=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=88=D0=B8=D1=82=D1=8C=20thin-market=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=20IMV=20=E2=80=94=20Guard-1b=20=D0=B8=20GET-=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 при переоткрытии. --- tradein-mvp/backend/app/api/v1/trade_in.py | 11 +++- tradein-mvp/backend/app/services/estimator.py | 8 ++- .../backend/tests/test_estimator_imv_blend.py | 18 ++++++ .../tests/test_estimator_quarter_index.py | 56 ++++++++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 61e086cd..e5542b73 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1a5fae76..3541e3fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 024bbf5e..3decd220 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index dea55ceb..13b89e2d 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -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). -- 2.45.3