diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index f95e6571..b3e6cdf9 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1802,6 +1802,7 @@ def _elasticity_coef( district_name: str, target_class: str | None, elasticity_window_months: int = 24, + districts: list[str] | None = None, ) -> dict[str, Any]: """Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on objective_corpus_room_month observations for the same район+class. @@ -1814,6 +1815,21 @@ def _elasticity_coef( price_avg → deals_total_avg_price_thousand_rub_per_m2. LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope не зависит от единиц (аддитивный сдвиг в LN пространстве). + + District filter (#1211): + • ``districts is None`` → legacy: фильтр ``crm.district = :dn`` по + ``district_name`` (back-compat для caller'ов с admin-vocab источником — + напр. ``analytics_queries`` использует ``domrf_kn_objects.district_name``; + отдельный bug class). + • ``districts=[]`` → EKB-wide (без district-фильтра). Используется когда + резолвер вернул None (вход = 'не определён' / нет чистых алиасов). + • ``districts=[m1, m2, ...]`` → ``crm.district = ANY(CAST(:districts AS text[]))``. + ``district_name`` тогда используется только для logging. + + ``objective_corpus_room_month.district`` — МИКРО-вокабуляр ЕКБ (тот же, что + ``objective_lots.district``: 'Втузгородок', 'ЖБИ', …). Передача сюда + admin-имени ('Кировский') даёт 0 точек → всегда FALLBACK (silent-correctness + bug #1211). Резолв admin→micros — обязанность вызывающего. """ where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" params: dict[str, Any] = { @@ -1823,6 +1839,18 @@ def _elasticity_coef( if target_class: params["cls"] = target_class _ = region_code # retained for backward compat; objective data covers EKB only + + if districts is None: + # Legacy path: один district_name (admin-vocab caller'ы остаются как были). + where_district = "AND crm.district = :dn" + elif districts: + # Резолвнутый набор МИКРО-районов — bind через CAST(:x AS text[]). + where_district = "AND crm.district = ANY(CAST(:districts AS text[]))" + params["districts"] = list(districts) + else: + # Пустой список — EKB-wide fallback (резолвер вернул None → нечего сужать). + where_district = "" + row = ( db.execute( text( @@ -1832,9 +1860,9 @@ def _elasticity_coef( LN(crm.deals_total_count::float8) AS y, LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x FROM objective_corpus_room_month crm - WHERE crm.district = :dn + WHERE crm.deals_total_count > 0 + {where_district} {where_class} - AND crm.deals_total_count > 0 AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0 AND crm.report_month >= NOW() - (:ew || ' months')::interval ) diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index 4af7c1ab..a9020444 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -512,9 +512,19 @@ def _price_sensitivity( Требует district (регрессия по району). Без district → None (нечего фитить). elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории, поэтому минимум 24 мес (как в recommend_mix). + + #1211 fix: District-вход — admin-имя ЕКБ (из /analyze), но + ``objective_corpus_room_month.district`` — МИКРО-вокабуляр (как и + ``objective_lots.district``). Сырое admin-имя в фильтр давало 0 точек + регрессии → всегда FALLBACK_ELASTICITY (silent-correctness). Резолвим + admin → набор микро через ``resolve_objective_districts`` и передаём + списком в ``_elasticity_coef(..., districts=...)``. Резолвер None + (нет чистых алиасов / 'не определён') → пустой список → EKB-wide + регрессия (лучше, чем 0 точек: получаем агрегированный сигнал). """ if not district: return None, None + micros = resolve_objective_districts(db, district) elasticity_window = max(window_months, 24) try: elast = _elasticity_coef( @@ -523,8 +533,13 @@ def _price_sensitivity( district_name=district, target_class=None, elasticity_window_months=elasticity_window, + districts=micros if micros is not None else [], ) except Exception: - logger.exception("market_metrics: elasticity reuse failed (district=%s)", district) + logger.exception( + "market_metrics: elasticity reuse failed (district=%s micros=%s)", + district, + micros, + ) return None, None return float(elast["elasticity"]), str(elast["source"]) diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index 833e0faa..917b4cea 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -30,6 +30,7 @@ from app.services.site_finder.market_metrics import ( _monthly_rate, _months_of_supply, _overstock_index, + _price_sensitivity, _room_bucket, _sell_through_pct, compute_market_metrics, @@ -457,9 +458,12 @@ class TestDistrictResolution: with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): m = compute_market_metrics(db, district="Кировский") - # резолвер вызван именно с админ-именем - _patch_resolver.assert_called_once() - assert _patch_resolver.call_args.args[1] == "Кировский" + # Резолвер вызван с админ-именем. #1211: теперь зовётся ДВАЖДЫ — раз для + # stock/sales фильтра (compute_market_metrics), раз для _price_sensitivity + # → _elasticity_coef. Оба вызова — с одним и тем же admin-именем. + assert _patch_resolver.called + for call in _patch_resolver.call_args_list: + assert call.args[1] == "Кировский" # КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк). for call_idx in (0, 1): # stock + sales-window @@ -500,6 +504,101 @@ class TestDistrictResolution: assert "Орджоникидзевский" not in p["districts"] +class TestPriceSensitivityDistrictResolution: + """#1211 P2 silent-correctness: _price_sensitivity резолвит admin → МИКРО, а НЕ + передаёт сырое admin-имя в _elasticity_coef. + + Корень бага: ``objective_corpus_room_month.district`` — МИКРО-вокабуляр + (Втузгородок, ЖБИ, …); фильтр по admin-имени ('Кировский') даёт 0 точек → + регрессия всегда FALLBACK_ELASTICITY (silent-correctness — §9.2 district-level + эластичность никогда не считалась в /analyze-пути; только 'Академический' + случайно совпадал в обоих вокабулярах). + """ + + def test_admin_resolves_to_micros_in_elasticity_call( + self, _patch_resolver: MagicMock + ) -> None: + # Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде). + _patch_resolver.side_effect = lambda _db, d: ( + ["Втузгородок", "ЖБИ"] if d == "Кировский" else None + ) + db = MagicMock() + with patch(_ELAST, return_value={"elasticity": -1.2, "source": "regression"}) as elast: + value, source = _price_sensitivity(db, district="Кировский", window_months=6) + + # резолвер вызван именно с admin-именем + _patch_resolver.assert_called_once() + assert _patch_resolver.call_args.args[1] == "Кировский" + + # КРИТИЧНО: в _elasticity_coef ушёл список МИКРО, а НЕ сырое admin-имя. + _args, kwargs = elast.call_args + assert kwargs["districts"] == ["Втузгородок", "ЖБИ"] + assert kwargs["district_name"] == "Кировский" # сохранён для logging + # elasticity-окно минимум 24 мес даже при коротком window_months + assert kwargs["elasticity_window_months"] == 24 + + # результат _elasticity_coef переброшен наружу + assert value == -1.2 + assert source == "regression" + + def test_resolver_none_passes_empty_list_ekb_wide( + self, _patch_resolver: MagicMock + ) -> None: + # Резолвер None ('не определён' / нет чистых алиасов) → передаём [] → + # _elasticity_coef уходит в EKB-wide ветку (без district-фильтра). + _patch_resolver.side_effect = lambda _db, _d: None + db = MagicMock() + with patch(_ELAST, return_value={"elasticity": -1.5, "source": "regression"}) as elast: + _price_sensitivity(db, district="не определён", window_months=6) + + _args, kwargs = elast.call_args + assert kwargs["districts"] == [] + + def test_none_district_skips_resolver_and_elasticity( + self, _patch_resolver: MagicMock + ) -> None: + # district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни + # резолвер, ни _elasticity_coef. + db = MagicMock() + with patch(_ELAST) as elast: + value, source = _price_sensitivity(db, district=None, window_months=6) + assert value is None + assert source is None + assert not _patch_resolver.called + assert not elast.called + + def test_post_resolution_regression_returns_non_fallback( + self, _patch_resolver: MagicMock + ) -> None: + # Симулируем «после резолва n>=30 точек, регрессия валидна»: _elasticity_coef + # возвращает source='regression' с конкретным slope — _price_sensitivity не + # подменяет результат и не схлопывается в fallback. + _patch_resolver.side_effect = lambda _db, d: ( + ["Эльмаш"] if d == "Орджоникидзевский" else None + ) + db = MagicMock() + with patch( + _ELAST, return_value={"elasticity": -0.87, "r2": 0.42, "n": 45, "source": "regression"} + ): + value, source = _price_sensitivity(db, district="Орджоникидзевский", window_months=6) + # источник 'regression' (не 'fallback'), значит admin→micro резолв реально дал + # точки регрессии — это и есть фикс бага #1211. + assert source == "regression" + assert value == -0.87 + + def test_elasticity_exception_returns_none_pair_graceful( + self, _patch_resolver: MagicMock + ) -> None: + # Багу резолва ортогональна graceful-обёртка: если _elasticity_coef падает, + # _price_sensitivity ловит и возвращает (None, None), а не крашит pipeline. + _patch_resolver.side_effect = lambda _db, d: ["Втузгородок"] + db = MagicMock() + with patch(_ELAST, side_effect=RuntimeError("db down")): + value, source = _price_sensitivity(db, district="Кировский", window_months=6) + assert value is None + assert source is None + + class TestComputeMarketMetricsThinData: """Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""