fix(forecasting): thread room_bucket velocity into format ranking (#1593) #1696

Merged
lekss361 merged 2 commits from fix/base-pace-room-bucket-1593 into main 2026-06-17 18:08:26 +00:00
4 changed files with 191 additions and 35 deletions

View file

@ -30,15 +30,14 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace
(0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
ВАЖНО (#1593): сегодня §9.2 unit_velocity (base_pace) и §9.4 norm.coefficient
ОДИНАКОВЫ для всех 5 room-bucket'ов (market_metrics.compute_market_metrics не
принимает room_bucket, а §9.4-β фитится district×obj_class без room_bucket),
поэтому оба постоянных множителя СОКРАЩАЮТСЯ в pace/max_pace и порядок форматов
определяется ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef = segment_steepness(room_bucket).
Т.е. фактический deficit_index в demand_only = ПРОКСИ rate-крутизны нарезки, а
НЕ относительной силы спроса. Честный per-bucket §9.2/§9.4-сигнал требует
room_bucket-параметра в market_metrics + §9.4/§9.6 (другие файлы + доменное
решение, как мерить per-bucket спрос) см. #1593.
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room (ед./мес из objective_lots per комнатность).
Маппинг forecast_bucket metric-ключи: _FORECAST_TO_METRIC_BUCKETS.
"80+ м²" = сумма bucket'ов "4" + "5+" (оба крупные форматы). Если данных
по конкретному bucket'у нет — fallback на агрегатный unit_velocity, что
лучше чем фабрикация 0. §9.4 norm.coefficient по-прежнему без per-bucket
фита (β фитится district×obj_class), поэтому он одинаков между bucket'ами
и СОКРАЩАЕТСЯ в pace/max_pace это честно задокументировано.
ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
`forecasting/__init__` тянет `affordability`, который импортит
@ -76,6 +75,18 @@ _FORECAST_TO_LIVE_BUCKET: dict[str, str] = {
# дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse).
_LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()}
# #1593: forecast room-bucket → список ключей MarketMetrics.velocity_by_room.
# Ключи velocity_by_room — вокабуляр _room_bucket() из market_metrics
# ("студия","1","2","3","4","5+"). "80+ м²" накрывает 4-комнатные и 5+
# (большие квартиры), поэтому суммируем оба бакета.
_FORECAST_TO_METRIC_BUCKETS: dict[str, list[str]] = {
"Студии 15-30": ["студия"],
"1-к 30-45": ["1"],
"2-к 45-60": ["2"],
"3-к 60-80": ["3"],
"80+ м²": ["4", "5+"],
}
# Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий»
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
@ -576,14 +587,11 @@ def _demand_only_overlay(
(0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
фабрикуем supply. Graceful [].
ВАЖНО (#1593, честность ранкинга): §9.2 base_pace и §9.4 norm.coefficient
сейчас ОДИНАКОВЫ для всех bucket'ов (нет room_bucket-фильтра в
market_metrics.compute_market_metrics; §9.4-β фитится district×obj_class без
room_bucket) и СОКРАЩАЮТСЯ в pace/max_pace. Поэтому фактический порядок
форматов и deficit_index определяются ИСКЛЮЧИТЕЛЬНО §9.5 macro_coef =
segment_steepness(room_bucket) это ПРОКСИ rate-крутизны нарезки, НЕ силы
спроса. Полноценный per-bucket §9.2/§9.4-сигнал = другие файлы + доменное
решение (#1593); до тех пор не интерпретировать как «самый дефицитный спрос».
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room. §9.4 norm.coefficient одинаков между
bucket'ами (β без room_bucket фита) и СОКРАЩАЕТСЯ в pace/max_pace — это
задокументировано, не скрыто. Ранкинг теперь отражает реальные per-bucket
темпы спроса из objective_lots, а не только §9.5 macro_coef.
"""
# Локальные импорты — избегаем import-cycle (см. module docstring).
from app.services.forecasting.demand_normalization import compute_demand_normalization
@ -597,9 +605,10 @@ def _demand_only_overlay(
]
mapped_class = map_class(target_class)
# Один раз на вызов: §9.2 наблюдаемый темп (base_pace) + hold-last-rate для §9.4.
# Один раз на вызов: §9.2 наблюдаемый темп (aggregate + per-bucket) + hold-last-rate.
metrics = compute_market_metrics(db, district=district)
base_pace = metrics.unit_velocity
base_pace = metrics.unit_velocity # агрегатный fallback (используется если нет per-bucket)
vel_by_room = metrics.velocity_by_room # #1593: per-bucket из objective_lots
macro = get_monthly_macro(db)
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
@ -625,6 +634,22 @@ def _demand_only_overlay(
room_bucket=forecast_bucket,
district=district,
)
# §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots
# per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей
# для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по
# бакетам. Честный 0 при известных бакетах — НЕ fallback. Fallback на
# агрегатный base_pace только когда velocity_by_room=None (пустая
# выборка) или bucket неизвестен в _FORECAST_TO_METRIC_BUCKETS.
metric_keys = _FORECAST_TO_METRIC_BUCKETS.get(forecast_bucket, [])
if vel_by_room is not None and metric_keys:
bucket_velocity: float = sum(vel_by_room.get(k, 0.0) for k in metric_keys)
# Если нет продаж ни в одном из ключей — честный 0 (НЕ fallback к
# aggregate: лучше показать реальный 0 чем маскировать его aggregate).
bucket_base_pace: float = bucket_velocity
else:
# vel_by_room None (пустая выборка) или неизвестный forecast_bucket →
# aggregate fallback сохраняет graceful поведение.
bucket_base_pace = float(base_pace)
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
# delta=rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
@ -640,7 +665,7 @@ def _demand_only_overlay(
if mapped_class is not None:
profile["obj_class"] = mapped_class
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
pace = base_pace * norm_coefficient * macro_coef.coefficient
pace = bucket_base_pace * norm_coefficient * macro_coef.coefficient
paces.append((live_bucket, mapped_class, pace))
max_pace = max((p for _, _, p in paces), default=0.0)
@ -656,9 +681,10 @@ def _demand_only_overlay(
demand_only=True,
)
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1]. NB #1593: base_pace и
# §9.4 norm одинаковы для всех bucket'ов и сокращаются → порядок = §9.5
# segment_steepness (rate-крутизна), НЕ сила спроса. Per-bucket §9.2/§9.4 = TODO.
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1].
# #1593: base_pace теперь per-bucket из velocity_by_room → ранкинг отражает
# реальный спрос по комнатности. §9.4 norm одинаков между bucket'ами и
# сокращается в pace/max_pace (β без per-bucket фита — честно задокументировано).
paces.sort(key=lambda t: t[2], reverse=True)
ranked_segments: list[dict[str, Any]] = [
{
@ -672,12 +698,13 @@ def _demand_only_overlay(
]
logger.info(
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d base_pace=%.3f "
"(ADVISORY, supply НЕ учтён)",
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d "
"base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)",
district,
horizon_months,
len(ranked_segments),
base_pace,
vel_by_room,
)
return _enrich_overlay(
db,

View file

@ -65,6 +65,7 @@ def _market_metrics_key(
premise_kind,
)
Confidence = Literal["high", "medium", "low"]
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
@ -111,6 +112,9 @@ class MarketMetrics:
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
price_sensitivity_source: str | None # 'regression' | 'fallback' | None
# #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
# "студия","1","2","3","4","5+"). None если нет данных в окне.
velocity_by_room: dict[str, float] | None
def as_dict(self) -> dict[str, Any]:
return {
@ -136,6 +140,8 @@ class MarketMetrics:
"demand_concentration": _round_or_none(self.demand_concentration, 3),
"price_sensitivity": _round_or_none(self.price_sensitivity, 4),
"price_sensitivity_source": self.price_sensitivity_source,
# velocity_by_room намеренно не сериализуется: это внутренний
# pipeline-атрибут (потребляется recommendation.py напрямую).
}
@ -409,6 +415,15 @@ def compute_market_metrics(
liquidity = _liquidity_index(sold_by_room)
overstock = _overstock_index(n_long_unsold, n_available)
demand_conc = _demand_concentration(sold_by_room)
# #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
# _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
# смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
# window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
vel_by_room: dict[str, float] | None = (
{bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
if has_sample and sold_by_room and window_months > 0
else None
)
# ── price_sensitivity — reuse analytics_queries._elasticity_coef ───────────
price_sensitivity, price_sensitivity_source = _price_sensitivity(
@ -450,6 +465,7 @@ def compute_market_metrics(
demand_concentration=demand_conc,
price_sensitivity=price_sensitivity,
price_sensitivity_source=price_sensitivity_source,
velocity_by_room=vel_by_room,
)

View file

@ -329,9 +329,13 @@ class TestDemandSupplyGraceful:
# ── build_forecast_overlay: demand_only (cad_num=None) ─────────────────────────
def _mk_metrics(unit_velocity: float | None) -> MagicMock:
def _mk_metrics(
unit_velocity: float | None,
velocity_by_room: dict[str, float] | None = None,
) -> MagicMock:
m = MagicMock()
m.unit_velocity = unit_velocity
m.velocity_by_room = velocity_by_room
return m
@ -453,6 +457,71 @@ class TestDemandOnlyOverlay:
assert out["ranked_segments"] == []
assert out["warnings"]
def test_per_bucket_velocity_drives_ranking(self) -> None:
"""#1593: velocity_by_room даёт реальные per-bucket §9.2-темпы.
При одинаковом §9.5 macro_coef=1.0 ранкинг должен определяться
исключительно per-bucket velocity_by_room, а не aggregate base_pace.
Студии ("студия") самый высокий темп первые в ранкинге.
"""
# velocity_by_room: студии продаются в 5× быстрее чем 1-к, остальные ≈ 0.
vel_by_room = {
"студия": 10.0, # "Студии 15-30" → "студия"
"1": 2.0, # "1-к 30-45" → "1"
"2": 1.0, # "2-к 45-60" → "2"
"3": 0.5, # "3-к 60-80" → "3"
"4": 0.1, # "80+ м²" → "4" + "5+"
"5+": 0.1,
}
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=vel_by_room)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, return_value=_mk_coef(1.0)), # нейтральный §9.5 (изолируем §9.2)
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
buckets = [s["bucket"] for s in out["ranked_segments"]]
# При одинаковом macro_coef=1.0 ранкинг = velocity_by_room:
# студии (10.0) > 1-к (2.0) > 2-к (1.0) > 3-к (0.5) > 80+ (0.1+0.1=0.2)
assert buckets[0] == "1-Студия", f"ожидали студии первыми, получили {buckets}"
assert buckets[1] == "2-1-к", f"ожидали 1-к вторыми, получили {buckets}"
assert buckets[-1] == "5-80+ м²", f"ожидали 80+ м² последними, получили {buckets}"
# deficit_index нормирован к 1.0 для топа
assert out["ranked_segments"][0]["deficit_index"] == pytest.approx(1.0)
def test_fallback_to_aggregate_when_no_velocity_by_room(self) -> None:
"""#1593: при velocity_by_room=None возвращаемся к aggregate base_pace.
Ranking в этом случае определяется §9.5 macro_coef (старое поведение),
но не крашит и не фабрикует данные.
"""
macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=None)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
# Graceful: 5 сегментов, deficit_index ∈ (0, 1].
assert len(out["ranked_segments"]) == 5
assert max(s["deficit_index"] for s in out["ranked_segments"]) == pytest.approx(1.0)
def test_five_default_room_buckets_ranked(self) -> None:
with (
patch(_METRICS, return_value=_mk_metrics(4.0)),

View file

@ -275,6 +275,7 @@ class TestAsDict:
demand_concentration=0.3333333,
price_sensitivity=-1.234567,
price_sensitivity_source="regression",
velocity_by_room={"1": 1.5, "2": 0.5},
)
d = m.as_dict()
assert d["absorption_rate"] == 0.0476
@ -304,6 +305,7 @@ class TestAsDict:
demand_concentration=None,
price_sensitivity=None,
price_sensitivity_source=None,
velocity_by_room=None,
)
d = m.as_dict()
assert d["absorption_rate"] is None
@ -421,6 +423,14 @@ class TestComputeMarketMetricsShape:
assert m.price_sensitivity == -1.4
assert m.price_sensitivity_source == "regression"
assert m.confidence == "high"
# #1593: velocity_by_room — per-bucket ед./мес из sold_by_room.
# _FULL_SALES: rooms_int=1 (30 ед.), rooms_int=2 (20 ед.), rooms_int=0 (10 ед.)
# window_months=6 → "1": 30/6=5.0, "2": 20/6≈3.333, "студия": 10/6≈1.667
assert m.velocity_by_room is not None
assert set(m.velocity_by_room.keys()) == {"1", "2", "студия"}
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_elasticity_reuse_invoked_with_district(self) -> None:
db = _mock_db(_FULL_STOCK, _FULL_SALES)
@ -516,9 +526,7 @@ class TestPriceSensitivityDistrictResolution:
случайно совпадал в обоих вокабулярах).
"""
def test_admin_resolves_to_micros_in_elasticity_call(
self, _patch_resolver: MagicMock
) -> None:
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
@ -542,9 +550,7 @@ class TestPriceSensitivityDistrictResolution:
assert value == -1.2
assert source == "regression"
def test_resolver_none_passes_empty_list_ekb_wide(
self, _patch_resolver: MagicMock
) -> None:
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
@ -555,9 +561,7 @@ class TestPriceSensitivityDistrictResolution:
_args, kwargs = elast.call_args
assert kwargs["districts"] == []
def test_none_district_skips_resolver_and_elasticity(
self, _patch_resolver: MagicMock
) -> None:
def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
# резолвер, ни _elasticity_coef.
db = MagicMock()
@ -600,6 +604,45 @@ class TestPriceSensitivityDistrictResolution:
assert source is None
class TestVelocityByRoom:
"""#1593: MarketMetrics.velocity_by_room — per-bucket ед./мес."""
def test_velocity_by_room_computed_from_sold_by_room(self) -> None:
# sold_by_room: "1" → 30, "2" → 20, "студия" → 10 ед. за 6 мес.
db = _mock_db(_FULL_STOCK, _FULL_SALES)
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
assert m.velocity_by_room is not None
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_velocity_by_room_none_when_no_sample(self) -> None:
# n_lots=0 → выборка пуста → velocity_by_room None (зеркало unit_velocity).
empty_stock = {
"n_lots": 0,
"n_sold": 0,
"n_available": 0,
"obj_count": 0,
"n_long_unsold": 0,
}
db = _mock_db(empty_stock, [])
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="ПустойРайон")
assert m.velocity_by_room is None
def test_velocity_by_room_none_when_no_by_room_data(self) -> None:
# Только grand-total строка (нет разбивки по комнатности) → sold_by_room пустой
# → velocity_by_room None.
sales_total_only = [
{"units_sold_window": 5, "area_sold_window": 200.0, "rooms_int": None, "is_total": 1}
]
db = _mock_db(_FULL_STOCK, sales_total_only)
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="Тихий")
assert m.velocity_by_room is None
class TestComputeMarketMetricsThinData:
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
@ -623,6 +666,7 @@ class TestComputeMarketMetricsThinData:
assert m.liquidity_index is None
assert m.overstock_index is None
assert m.demand_concentration is None
assert m.velocity_by_room is None
assert m.confidence == "low"
def test_stock_none_row_no_crash(self) -> None: