From c9ba595abe21ec8d12f00771e5993826d59f32b0 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 17 Jun 2026 13:32:03 +0500 Subject: [PATCH] =?UTF-8?q?refactor(forecasting):=20rename=20commercial=5F?= =?UTF-8?q?share=5Fpct=20=E2=86=92=20commercial=5Fsell=5Fthrough=5Fpct=20(?= =?UTF-8?q?#1635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внутренний recommendation→product_scoring контракт-ключ был мислейблом: величина — темп распродажи нежилого (sell-through, прокси ликвидности/спроса), а НЕ доля нежилого в объёме застройки. Переименован ключ + исправлены reason/docstring/комментарии у потребителя _score_commercial. Числовая логика не изменена. Ключ внутренний (нет frontend/schema/openapi-потребителей) → rename контракт-безопасен. pytest 171 passed. Closes #1635 --- .../services/forecasting/product_scoring.py | 28 +++++++++++-------- .../services/forecasting/recommendation.py | 9 ++---- .../forecasting/test_product_scoring.py | 8 +++--- .../forecasting/test_recommendation.py | 2 +- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/backend/app/services/forecasting/product_scoring.py b/backend/app/services/forecasting/product_scoring.py index 846731aa..cffaa811 100644 --- a/backend/app/services/forecasting/product_scoring.py +++ b/backend/app/services/forecasting/product_scoring.py @@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0 # дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш). _DIFF_TARGET_USP: float = 3.0 -# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100. -# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False -# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию. +# commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого, +# sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): +# overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100 +# как прокси силы спроса на коммерцию. # confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] → # усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 — @@ -463,21 +464,24 @@ def _score_differentiation( def _score_commercial( commercial: dict[str, Any] | None, ) -> tuple[float | None, Confidence, str]: - """commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE. + """commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE. - overlay.commercial.available=True → commercial_share_pct/100 (прокси спроса на - нежилое). available=False / None (objective ~ жильё, тонко) → None (НЕ 0-как-заглушка). - confidence наследуется из сигнала (или 'low'). + overlay.commercial.available=True → commercial_sell_through_pct/100 (темп распродажи + нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко) + → None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low'). """ if not commercial or not commercial.get("available"): return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)." - share_pct = commercial.get("commercial_share_pct") - if share_pct is None: - return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)." + sell_through_pct = commercial.get("commercial_sell_through_pct") + if sell_through_pct is None: + return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)." raw_conf = commercial.get("confidence", "low") conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low" - value = _clamp01(float(share_pct) / 100.0) - reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)." + value = _clamp01(float(sell_through_pct) / 100.0) + reason = ( + f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси " + "ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)." + ) return value, conf, reason diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 3d6aff23..11654df8 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -774,12 +774,9 @@ def _commercial_signal( return { "available": True, "premise_kind": _COMMERCIAL_PREMISE_KIND, - # NB #1635: ключ `commercial_share_pct` — МИСЛЕЙБЛ (величина = темп - # распродажи sell_through, а не доля нежилого в объёме). Это контракт-ключ, - # читаемый product_scoring._score_commercial (product_scoring.py:474, чей - # reason на :480 повторяет тот же мислейбл) — переименование в - # commercial_sell_through_pct cross-file, координировать с product_scoring.py. - "commercial_share_pct": sell_through_pct, + # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси + # ликвидности/спроса), НЕ доля нежилого в объёме застройки. + "commercial_sell_through_pct": sell_through_pct, "n_lots": n_lots, "confidence": confidence, "reason": { diff --git a/backend/tests/services/forecasting/test_product_scoring.py b/backend/tests/services/forecasting/test_product_scoring.py index 4948d646..99e889c2 100644 --- a/backend/tests/services/forecasting/test_product_scoring.py +++ b/backend/tests/services/forecasting/test_product_scoring.py @@ -409,7 +409,7 @@ class TestScoreDifferentiation: class TestScoreCommercial: def test_available_share_scaled(self) -> None: - signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"} + signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"} value, conf, _r = _score_commercial(signal) assert value == pytest.approx(0.4) assert conf == "medium" @@ -417,7 +417,7 @@ class TestScoreCommercial: def test_in_range(self) -> None: for share in (0.0, 25.0, 100.0): value, _c, _r = _score_commercial( - {"available": True, "commercial_share_pct": share, "confidence": "low"} + {"available": True, "commercial_sell_through_pct": share, "confidence": "low"} ) assert value is not None assert 0.0 <= value <= 1.0 @@ -439,7 +439,7 @@ class TestScoreCommercial: def test_bad_confidence_falls_back_low(self) -> None: value, conf, _r = _score_commercial( - {"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"} + {"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"} ) assert value is not None assert conf == "low" @@ -728,7 +728,7 @@ def _patch_all( overlay: Any = None, ) -> Any: """Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом).""" - commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"} + commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"} forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)] competitors_rv = ( competitors if competitors is not None else _competitors_response_stub([0.4, 0.2]) diff --git a/backend/tests/services/forecasting/test_recommendation.py b/backend/tests/services/forecasting/test_recommendation.py index bbde74f9..32686bef 100644 --- a/backend/tests/services/forecasting/test_recommendation.py +++ b/backend/tests/services/forecasting/test_recommendation.py @@ -744,7 +744,7 @@ class TestCommercialSignal: out = _commercial_signal(MagicMock(), "Ленинский", 12) assert out is not None assert out["available"] is True - assert out["commercial_share_pct"] == 42.5 + assert out["commercial_sell_through_pct"] == 42.5 assert out["n_lots"] == 120 assert out["confidence"] == "medium" assert out["reason"]["advisory"] is True