refactor(forecasting): rename commercial_share_pct → commercial_sell_through_pct (#1635)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Successful in 1m40s
CI / openapi-codegen-check (push) Successful in 1m38s
CI / backend-tests (pull_request) Successful in 9m51s
CI / changes (push) Successful in 7s
CI / frontend-tests (pull_request) Successful in 53s
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI / backend-tests (push) Successful in 9m52s

Внутренний recommendation→product_scoring контракт-ключ был мислейблом: величина —
темп распродажи нежилого (sell-through, прокси ликвидности/спроса), а НЕ доля нежилого
в объёме застройки. Переименован ключ + исправлены reason/docstring/комментарии у
потребителя _score_commercial. Числовая логика не изменена. Ключ внутренний (нет
frontend/schema/openapi-потребителей) → rename контракт-безопасен. pytest 171 passed.

Closes #1635
This commit is contained in:
Light1YT 2026-06-17 13:32:03 +05:00
parent 249182b678
commit c9ba595abe
4 changed files with 24 additions and 23 deletions

View file

@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш). # дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
_DIFF_TARGET_USP: float = 3.0 _DIFF_TARGET_USP: float = 3.0
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100. # commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False # sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию. # overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
# как прокси силы спроса на коммерцию.
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] → # confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 — # усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
@ -463,21 +464,24 @@ def _score_differentiation(
def _score_commercial( def _score_commercial(
commercial: dict[str, Any] | None, commercial: dict[str, Any] | None,
) -> tuple[float | None, Confidence, str]: ) -> 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 (прокси спроса на overlay.commercial.available=True commercial_sell_through_pct/100 (темп распродажи
нежилое). available=False / None (objective ~ жильё, тонко) None (НЕ 0-как-заглушка). нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
confidence наследуется из сигнала (или 'low'). None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
""" """
if not commercial or not commercial.get("available"): if not commercial or not commercial.get("available"):
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)." return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
share_pct = commercial.get("commercial_share_pct") sell_through_pct = commercial.get("commercial_sell_through_pct")
if share_pct is None: if sell_through_pct is None:
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)." return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
raw_conf = commercial.get("confidence", "low") raw_conf = commercial.get("confidence", "low")
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low" conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
value = _clamp01(float(share_pct) / 100.0) value = _clamp01(float(sell_through_pct) / 100.0)
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)." reason = (
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
)
return value, conf, reason return value, conf, reason

View file

@ -774,12 +774,9 @@ def _commercial_signal(
return { return {
"available": True, "available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND, "premise_kind": _COMMERCIAL_PREMISE_KIND,
# NB #1635: ключ `commercial_share_pct` — МИСЛЕЙБЛ (величина = темп # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
# распродажи sell_through, а не доля нежилого в объёме). Это контракт-ключ, # ликвидности/спроса), НЕ доля нежилого в объёме застройки.
# читаемый product_scoring._score_commercial (product_scoring.py:474, чей "commercial_sell_through_pct": sell_through_pct,
# reason на :480 повторяет тот же мислейбл) — переименование в
# commercial_sell_through_pct cross-file, координировать с product_scoring.py.
"commercial_share_pct": sell_through_pct,
"n_lots": n_lots, "n_lots": n_lots,
"confidence": confidence, "confidence": confidence,
"reason": { "reason": {

View file

@ -409,7 +409,7 @@ class TestScoreDifferentiation:
class TestScoreCommercial: class TestScoreCommercial:
def test_available_share_scaled(self) -> None: 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) value, conf, _r = _score_commercial(signal)
assert value == pytest.approx(0.4) assert value == pytest.approx(0.4)
assert conf == "medium" assert conf == "medium"
@ -417,7 +417,7 @@ class TestScoreCommercial:
def test_in_range(self) -> None: def test_in_range(self) -> None:
for share in (0.0, 25.0, 100.0): for share in (0.0, 25.0, 100.0):
value, _c, _r = _score_commercial( 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 value is not None
assert 0.0 <= value <= 1.0 assert 0.0 <= value <= 1.0
@ -439,7 +439,7 @@ class TestScoreCommercial:
def test_bad_confidence_falls_back_low(self) -> None: def test_bad_confidence_falls_back_low(self) -> None:
value, conf, _r = _score_commercial( 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 value is not None
assert conf == "low" assert conf == "low"
@ -728,7 +728,7 @@ def _patch_all(
overlay: Any = None, overlay: Any = None,
) -> Any: ) -> Any:
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом).""" """Контекст-менеджер: патчит ВСЕ 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)] forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
competitors_rv = ( competitors_rv = (
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2]) competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])

View file

@ -744,7 +744,7 @@ class TestCommercialSignal:
out = _commercial_signal(MagicMock(), "Ленинский", 12) out = _commercial_signal(MagicMock(), "Ленинский", 12)
assert out is not None assert out is not None
assert out["available"] is True 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["n_lots"] == 120
assert out["confidence"] == "medium" assert out["confidence"] == "medium"
assert out["reason"]["advisory"] is True assert out["reason"]["advisory"] is True