fix(market-metrics): resolve admin→micros for _price_sensitivity (#1211)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

_price_sensitivity передавал сырое admin-имя ('Кировский') в _elasticity_coef,
который фильтрует objective_corpus_room_month.district по МИКРО-вокабуляру
(Втузгородок, ЖБИ, …) → регрессия получала 0 точек → всегда FALLBACK_ELASTICITY.
§9.2 district-level эластичность молча НЕ считалась в /analyze-пути (только
'Академический' совпадал в обоих вокабулярах случайно).

Fix: вызываем resolve_objective_districts() в _price_sensitivity и передаём
список микро через новый kwarg districts=[…] в _elasticity_coef. Резолвер
None ('не определён' / нет чистых алиасов) → пустой список → EKB-wide
регрессия. _elasticity_coef расширен с back-compat: districts=None →
legacy путь по district_name (другой caller в analytics_queries —
отдельный bug class, вне scope).

5 новых юнит-тестов TestPriceSensitivityDistrictResolution: admin→micros в
SQL bind, None→EKB-wide, regression preserved post-resolve, graceful.
76/76 market_metrics + 156/156 elasticity/sensitivity тестов зелёные.
ruff + psycopg v3 grep clean.

Closes #1211
This commit is contained in:
Light1YT 2026-06-13 10:52:14 +05:00 committed by bot-backend
parent 0a8785d548
commit 4c2f19ace0
3 changed files with 148 additions and 6 deletions

View file

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

View file

@ -528,9 +528,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(
@ -539,8 +549,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"])

View file

@ -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,
@ -458,9 +459,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
@ -501,6 +505,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."""