From 6cb3d7515c9d99d8ddf626f4ef4ae433596da4bf Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 28 May 2026 15:35:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20per-bucket=20velocity=20formula?= =?UTF-8?q?=20+=20realistic=20=D1=81=D1=80=D0=BE=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B6=D0=B8=20(#574)=20(#631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/analytics_queries.py | 188 ++++++- .../services/test_recommend_mix_velocity.py | 531 ++++++++++++++++++ 2 files changed, 695 insertions(+), 24 deletions(-) create mode 100644 backend/tests/services/test_recommend_mix_velocity.py diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 7276babb..a816ee54 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1706,6 +1706,95 @@ def _active_competitors_count( return 1, "fallback_singleton" +def _velocity_baseline_per_bucket( + db: Session, + *, + region_code: int, + district_name: str, + target_class: str | None, +) -> dict[str, float] | None: + """Per-bucket median velocity (units/month per ЖК) из objective_corpus_room_month. + + Группирует по room_bucket → для каждого бакета вычисляет median(month_units) + по проектам района/класса за последние 24 месяца. + + Маппинг room_bucket → _BUCKET_PRETTY ключи: + студия/studio/0 → '1-Студия' + 1 → '2-1-к' + 2 → '3-2-к' + 3 → '4-3-к' + 4/5+ → '5-80+ м²' + + Возвращает dict {bucket_id → median velocity} только для бакетов с данными, + или None если нет данных совсем (caller переходит на rosreestr-fallback). + _ region_code retained for backward compat; objective data covers EKB only. + """ + _ = region_code + where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else "" + params: dict[str, Any] = {"dn": district_name} + if target_class: + params["cls"] = target_class + + rows = ( + db.execute( + text( + f""" + WITH bucket_mapped AS ( + SELECT + CASE + WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0') + THEN '1-Студия' + WHEN crm.room_bucket = '1' THEN '2-1-к' + WHEN crm.room_bucket = '2' THEN '3-2-к' + WHEN crm.room_bucket = '3' THEN '4-3-к' + WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²' + ELSE NULL + END AS bucket_id, + crm.project_name, + crm.report_month, + crm.deals_total_count + FROM objective_corpus_room_month crm + WHERE crm.district = :dn + {where_class} + AND crm.deals_total_count > 0 + AND crm.report_month >= NOW() - INTERVAL '24 months' + ), + per_project_bucket_month AS ( + SELECT bucket_id, + project_name, + report_month, + SUM(deals_total_count) AS month_units + FROM bucket_mapped + WHERE bucket_id IS NOT NULL + GROUP BY bucket_id, project_name, report_month + ) + SELECT + bucket_id, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm, + COUNT(DISTINCT project_name) AS objects, + COUNT(*) AS observations + FROM per_project_bucket_month + GROUP BY bucket_id + """ + ), + params, + ) + .mappings() + .all() + ) + + if not rows: + return None + + result: dict[str, float] = {} + for r in rows: + v = _f(r["median_pm"]) + if v is not None and int(r["observations"] or 0) >= 3: + result[r["bucket_id"]] = v + + return result if result else None + + def _elasticity_coef( db: Session, *, @@ -2315,6 +2404,9 @@ def recommend_mix( sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"] # velocity_source label: "objective" when data available, "rosreestr_fallback" otherwise. # Value key kept as "sale_graph" in output for frontend backward-compat (no breaking change). + # After fix #574: per-bucket objective data (_velocity_baseline_per_bucket) is used even + # when aggregate sale_graph_vel_pm is None. velocity_source reflects aggregate source; + # per-bucket source is tracked in bucket["velocity_source"] added below. velocity_source = "objective" if sale_graph_vel_pm is not None else "rosreestr_fallback" elast = _elasticity_coef( @@ -2372,32 +2464,71 @@ def recommend_mix( f" нормировка по {competitors} ЖК в {scope_label}." ) - # 5b-2) market_vel_pm = «что продаёт ОДИН активный ЖК района за месяц». - # ИСТОЧНИК ИСТИНЫ — sale_graph (median realised per ЖК). При отсутствии — - # rosreestr-fallback: city-wide deals/mo / N_competitors → per-ЖК proxy. - # Это критично: per-ЖК baseline должен иметь правильную размерность - # (~3-7 кв/мес для ЕКБ ЖК), иначе months_to_sellout получается - # нереалистично коротким. + # 5b-2) Per-bucket market velocity (fix #574: per-bucket formula, realistic срок). + # + # BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем + # bucket_v = market_vel_pm × share/100 → all buckets scale proportionally → + # aggregate velocity ≡ market_vel_pm независимо от mix (slider static bug). + # При competitors_district≈1 получаем 159 кв/мес (темп РЫНКА, не проекта). + # + # FIX: per-bucket velocity вычисляется независимо для каждого бакета: + # objective path: _velocity_baseline_per_bucket → median per ЖК per room_bucket + # rosreestr fallback: bucket_deals / months / n_comp (district+class competitors) + # + # Это позволяет mix-слайдерам реально менять aggregate KPI, т.к. velocities + # студий, 1к и т.д. теперь независимые константы, не производные от share. + + # Objective path: per-bucket velocities из objective_corpus_room_month + obj_per_bucket = _velocity_baseline_per_bucket( + db, + region_code=region_code, + district_name=district_row["district_name"], + target_class=target_class_for_geo, + ) + + # n_comp — district+class конкуренты (_active_competitors_count каскад, + # уже вычислено выше как competitors_weighted). Делим темп РЫНКА района/класса + # на число активных ЖК этого района/класса → среднерыночный темп одного проекта. + # НЕ region-wide (~442 ЖК) — это давало срок 379-1180 мес (нереалистично). + n_comp = max(round(competitors_weighted), 1) # district+class competitors, NOT region-wide + + # Aggregate market_vel_pm (сохраняем для scope/output, не для bucket расчётов) if sale_graph_vel_pm is not None: market_vel_pm = sale_graph_vel_pm else: - warnings.append( - "Нет sale_graph данных для этого района и класса —" - " темп считается по rosreestr-сделкам ÷ конкуренты (грубее)." - ) + # Rosreestr fallback aggregate: district+class deals / months / n_comp = + # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес). market_vel_pm = ( - (total_deals / max(effective_window, 1) / max(competitors_weighted, 1.0)) - if total_deals and competitors_weighted - else 0.0 + (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0 + ) + warnings.append( + f"Нет objective-данных для района/класса — темп по rosreestr ÷ " + f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)." ) - # 5b-2.5) Per-bucket market velocity = market_vel_pm × share / 100. - # Аллоцируем единый per-ЖК baseline на размерные сегменты по shares - # (одинаковая модель для sale_graph и rosreestr_fallback). Студии/1к - # получат больший абсолютный темп если их share высокая в районе. - bucket_market_velocities = { - b["bucket"]: market_vel_pm * (b["share_pct"] / 100.0) for b in buckets - } + # 5b-2.5) Per-bucket market velocity (fix #574). + # + # Objective path: используем per-bucket velocities из objective_corpus_room_month. + # Для бакетов без данных в objective — fallback к rosreestr per-bucket. + # Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors). + # КРИТИЧНО: bucket_deal_counts — это MARKET deal counts (независимы от share_pct + # пользователя), поэтому per-bucket velocities — независимые константы → + # mix-слайдеры реально влияют на aggregate KPI (static-mix bug fix). + bucket_deal_counts = {r["bucket"]: int(r["deals"] or 0) for r in bucket_rows} + + bucket_market_velocities: dict[str, float] = {} + for b in buckets: + bkey = b["bucket"] + bkt_id = next((k for k, v in _BUCKET_PRETTY.items() if v == bkey), bkey) + # Objective per-bucket (preferred): median units/month per ЖК в этом бакете + if obj_per_bucket and bkt_id in obj_per_bucket: + bucket_market_velocities[bkey] = obj_per_bucket[bkt_id] + else: + # Rosreestr fallback per-bucket: market bucket_deals / months / n_comp. + # n_comp — district+class competitors (НЕ region-wide ~442), иначе срок + # выходит 379-1180 мес. bucket_deal_counts — market (не user share). + raw_deals = bucket_deal_counts.get(bkt_id, 0) + bucket_market_velocities[bkey] = raw_deals / max(effective_window, 1) / n_comp # 5b-2.5) Дополнительные district-specific signals (Tier 2): # sat_factor — насколько зрелый рынок района (median sold% активных @@ -2436,10 +2567,9 @@ def recommend_mix( success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class) # 5b-3) Per-bucket project velocity at price_factor=1.0: - # bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК - # темпа, аллоцированная на размерный сегмент. - # market_vel_pm УЖЕ per-ЖК (median sale_graph либо - # rosreestr/N_competitors), доп. нормировка не нужна. + # bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region. + # После fix #574: каждый бакет имеет независимую скорость + # (не производную от share) → mix-слайдеры реально меняют KPI. # project_velocity = bucket_market_v × sat_factor × trend_factor # sat — зрелый рынок ускоряет; trend — текущая # динамика (горит/остывает). @@ -2454,6 +2584,14 @@ def recommend_mix( total_units = 0 for b in buckets: bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0) + bkt_id_for_src = next( + (k for k, v in _BUCKET_PRETTY.items() if v == b["bucket"]), b["bucket"] + ) + b["velocity_source"] = ( + "objective_per_bucket" + if (obj_per_bucket and bkt_id_for_src in obj_per_bucket) + else "rosreestr_fallback" + ) bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4) b["velocity_per_month"] = bucket_velocity # Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty). @@ -2683,6 +2821,8 @@ def recommend_mix( round(market_vel_pm, 3) if market_vel_pm is not None else None ), "velocity_source": velocity_source, + # fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback + "n_competitors": n_comp, "velocity_observations": vel["observations"], "velocity_objects": vel["objects_count"], "competitors_count": competitors, diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py new file mode 100644 index 00000000..51bd7a92 --- /dev/null +++ b/backend/tests/services/test_recommend_mix_velocity.py @@ -0,0 +1,531 @@ +"""Tests for recommend_mix per-bucket velocity (fix #574). + +Проверяет: +1. Velocity varies per bucket based on MARKET rosreestr deals (static mix bug fixed). +2. Срок продажи реалистичный (12-24 мес) при rosreestr fallback — + нормировка по district+class competitors, НЕ region-wide ЖК. +3. Per-bucket velocities are independent constants (не производные от share_pct). +4. Rosreestr fallback uses district+class competitors_weighted (NOT ~442 region-wide). +5. Objective per-bucket path correctly applies per-bucket medians. + +Mock-based — не требуют живой БД. Тесты работают через patch() helper-функций +analytics_queries + прямые unit-тесты helper-функций. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Путь к тестируемому модулю +_MOD = "app.services.analytics_queries" + + +# ── Константы тестовых данных ──────────────────────────────────────────────── + +# Примерные district+class rosreestr данные: 5 бакетов, ~3800 сделок за 24 мес. +_CITY_BUCKET_DEALS = { + "1-Студия": 710, + "2-1-к": 1306, + "3-2-к": 980, + "4-3-к": 560, + "5-80+ м²": 244, +} +_TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values()) # 3800 + + +def _make_bucket_row( + bucket_id: str, deals: int, area_avg: float = 40.0 +) -> MagicMock: + r = MagicMock() + data = { + "bucket": bucket_id, + "deals": deals, + "area_avg": area_avg, + "area_median": area_avg * 0.95, + "price_median": 110_000.0, + "price_p25": 100_000.0, + "price_p75": 120_000.0, + } + r.__getitem__ = lambda self, k: data[k] + return r + + +def _city_bucket_rows() -> list[MagicMock]: + area_by_bucket = { + "1-Студия": 27.0, + "2-1-к": 38.0, + "3-2-к": 55.0, + "4-3-к": 72.0, + "5-80+ м²": 95.0, + } + return [ + _make_bucket_row(bid, deals, area_by_bucket.get(bid, 40.0)) + for bid, deals in _CITY_BUCKET_DEALS.items() + ] + + +# ── Helpers для unit-tests helper-функций ─────────────────────────────────── + + +def _make_scalar_result(value: Any) -> MagicMock: + r = MagicMock() + r.scalar.return_value = value + return r + + +def _make_mapping_result(rows: list) -> MagicMock: + r = MagicMock() + r.mappings.return_value.all.return_value = rows + r.mappings.return_value.first.return_value = rows[0] if rows else None + return r + + +# ── Tests: helper-функции ─────────────────────────────────────────────────── + + +class TestVelocityBaselinePerBucket: + """Unit tests для _velocity_baseline_per_bucket.""" + + def test_returns_none_when_no_rows(self) -> None: + from app.services.analytics_queries import _velocity_baseline_per_bucket + + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [] + result = _velocity_baseline_per_bucket( + db, region_code=66, district_name="Ленинский", target_class=None + ) + assert result is None + + def test_returns_per_bucket_velocities(self) -> None: + from app.services.analytics_queries import _velocity_baseline_per_bucket + + db = MagicMock() + rows = [] + for bid, median_pm, obs in [ + ("1-Студия", 2.5, 10), + ("2-1-к", 4.8, 15), + ("3-2-к", 3.2, 12), + ]: + r = MagicMock() + r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: { + "bucket_id": _bid, "median_pm": _med, "observations": _obs, + }[k] + rows.append(r) + db.execute.return_value.mappings.return_value.all.return_value = rows + + result = _velocity_baseline_per_bucket( + db, region_code=66, district_name="Ленинский", target_class=None + ) + assert result is not None + assert "1-Студия" in result + assert result["1-Студия"] == pytest.approx(2.5, rel=0.01) + assert result["2-1-к"] == pytest.approx(4.8, rel=0.01) + + def test_skips_buckets_with_few_observations(self) -> None: + """Бакеты с < 3 наблюдениями пропускаются.""" + from app.services.analytics_queries import _velocity_baseline_per_bucket + + db = MagicMock() + rows = [] + for bid, median_pm, obs in [ + ("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем + ("2-1-к", 5.0, 10), # OK + ]: + r = MagicMock() + r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: { + "bucket_id": _bid, "median_pm": _med, "observations": _obs, + }[k] + rows.append(r) + db.execute.return_value.mappings.return_value.all.return_value = rows + + result = _velocity_baseline_per_bucket( + db, region_code=66, district_name="Ленинский", target_class=None + ) + assert result is not None + assert "1-Студия" not in result, "Бакет с < 3 наблюдениями должен быть пропущен" + assert "2-1-к" in result + + def test_returns_none_when_all_too_few(self) -> None: + """Если все бакеты с < 3 obs — возвращает None.""" + from app.services.analytics_queries import _velocity_baseline_per_bucket + + db = MagicMock() + rows = [] + for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]: + r = MagicMock() + r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: { + "bucket_id": _bid, "median_pm": 3.0, "observations": _obs, + }[k] + rows.append(r) + db.execute.return_value.mappings.return_value.all.return_value = rows + + result = _velocity_baseline_per_bucket( + db, region_code=66, district_name="Ленинский", target_class=None + ) + assert result is None + + +# ── Tests: bucket_market_velocities через rosreestr fallback ───────────────── + + +class TestRosreestrFallbackPerBucketVelocity: + """Формула bucket_v = bucket_deals / months / n_comp (district+class competitors).""" + + def _compute_expected_bucket_v( + self, bucket_id: str, months: int = 24, n_comp: int = 10 + ) -> float: + deals = _CITY_BUCKET_DEALS[bucket_id] + return deals / months / n_comp + + def test_studio_velocity_correct(self) -> None: + """Студии: 710 сделок / 24 мес / 10 ЖК = 2.96 кв/мес.""" + expected = self._compute_expected_bucket_v("1-Студия", n_comp=10) + assert expected == pytest.approx(710 / 24 / 10, rel=0.01) + + def test_studio_less_than_one_k(self) -> None: + """Студии имеют меньше сделок чем 1к → меньше velocity.""" + v_studio = self._compute_expected_bucket_v("1-Студия", n_comp=10) + v_one_k = self._compute_expected_bucket_v("2-1-к", n_comp=10) + assert v_studio < v_one_k + + def test_velocity_independent_of_share(self) -> None: + """Velocity бакета НЕ зависит от share_pct пользователя (static-mix fix). + + Суть fix'а #574: per-bucket velocity = MARKET bucket_deals / months / n_comp. + bucket_deals берётся из рыночного распределения (rosreestr), а НЕ из доли + бакета в миксе пользователя. Поэтому сдвиг слайдера mix меняет units_planned + бакета, но НЕ его velocity → aggregate срок реально меняется при изменении mix. + """ + months, n_comp = 24, 10 + v_studio = 710 / months / n_comp + v_one_k = 1306 / months / n_comp + + # v_studio/v_one_k == deals_studio/deals_one_k (market ratio, не user share) + assert v_studio / v_one_k == pytest.approx(710 / 1306, rel=0.01) + + def test_velocity_scales_with_competitor_count(self) -> None: + """При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК).""" + v_few = 710 / 24 / 5 # 5 конкурентов + v_many = 710 / 24 / 15 # 15 конкурентов + assert v_few > v_many + assert v_few == pytest.approx(710 / 24 / 5, rel=0.001) + assert v_many == pytest.approx(710 / 24 / 15, rel=0.001) + + +# ── Tests: полный recommend_mix с минимальными моками ─────────────────────── + + +def _make_full_mock_db(has_class_data: bool = False) -> MagicMock: + """DB mock с разумными ответами на все прямые db.execute() вызовы. + + Все helper-функции (_velocity_baseline, _bucket_distribution, etc.) + патчатся снаружи через patch(). Этот mock покрывает только ПРЯМЫЕ + db.execute вызовы внутри recommend_mix: + 1. district_row query + 2. city_median scalar + 3. has_class_data scalar + 4. comparables query (большой → возвращаем пустой список) + """ + db = MagicMock() + + # district_row + dr = MagicMock() + dr.__getitem__ = lambda self, k: { + "district_name": "Ленинский", + "zk_count": 12, + "flat_count": 5000, + "median_price_per_m2": 110_000.0, + "mean_price_per_m2": 112_000.0, + }[k] + + # Sequence для прямых db.execute calls + calls: list[MagicMock] = [] + + # 1) district_row + r1 = MagicMock() + r1.mappings.return_value.first.return_value = dr + calls.append(r1) + + # 2) city_median scalar + r2 = MagicMock() + r2.scalar.return_value = 110_000.0 + calls.append(r2) + + # 3) has_class_data scalar + r3 = MagicMock() + r3.scalar.return_value = 1 if has_class_data else None + calls.append(r3) + + # 4) comparables query → пустой + r4 = MagicMock() + r4.mappings.return_value.all.return_value = [] + calls.append(r4) + + db.execute.side_effect = calls + return db + + +def _run_recommend_mix_full( + *, + objective_per_bucket: dict[str, float] | None, + n_competitors: float = 10.0, + sale_graph_vel_pm: float | None = None, + area_total_m2: float = 12_000.0, +) -> dict[str, Any]: + """Запускает recommend_mix с правильным набором моков. + + n_competitors — district+class competitors_weighted, который возвращает + _competitors_two_dim и используется как знаменатель в rosreestr fallback. + """ + from app.services.analytics_queries import recommend_mix + + db = _make_full_mock_db() + + patches = [ + patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()), + patch( + f"{_MOD}._velocity_baseline", + return_value={ + "realised_per_month_median": sale_graph_vel_pm, + "realised_per_month_avg": sale_graph_vel_pm, + "objects_count": 5 if sale_graph_vel_pm else 0, + "observations": 20 if sale_graph_vel_pm else 0, + }, + ), + patch(f"{_MOD}._velocity_baseline_per_bucket", return_value=objective_per_bucket), + patch( + f"{_MOD}._elasticity_coef", + return_value={"elasticity": -1.5, "r2": 0.0, "n": 0, "source": "fallback"}, + ), + patch(f"{_MOD}._elasticity_per_bucket_coef", return_value={}), + # competitors_weighted = n_competitors → знаменатель rosreestr fallback + patch( + f"{_MOD}._competitors_two_dim", + return_value=(int(n_competitors), 5, float(n_competitors), "district_2d"), + ), + patch(f"{_MOD}._district_market_saturation", return_value=(50.0, 8)), + patch(f"{_MOD}._district_velocity_trend", return_value=(1.0, 100, 100)), + patch(f"{_MOD}._district_poi_score", return_value=None), + patch(f"{_MOD}._city_avg_poi_score", return_value=None), + patch( + f"{_MOD}._district_cadastre_baseline", + return_value={"median_per_m2": None, "buildings_n": 0}, + ), + patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)), + patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])), + patch(f"{_MOD}._bucket_success_ranking", return_value=[]), + ] + + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patches[4], + patches[5], + patches[6], + patches[7], + patches[8], + patches[9], + patches[10], + patches[11], + patches[12], + patches[13], + ): + return recommend_mix( + db, + district_name="Ленинский", + area_total_m2=area_total_m2, + target_class=None, + months_window=24, + region_code=66, + ) + + +class TestRealisticSrokFallback: + """Bug #574 Bug_Velocity_Unrealistic: rosreestr fallback даёт реалистичный срок.""" + + def test_market_vel_pm_normalized_by_competitors(self) -> None: + """scope.market_velocity_per_month = total_deals / months / n_comp. + + n_comp — district+class competitors_weighted (~5-15), НЕ region-wide ЖК (~442). + """ + n_comp = 10 + months = 24 + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=n_comp, + sale_graph_vel_pm=None, + ) + scope = result["scope"] + total_deals = scope["total_deals"] + actual_vel = scope["market_velocity_per_month"] + expected_vel = total_deals / months / n_comp + assert actual_vel == pytest.approx(expected_vel, rel=0.02), ( + f"market_vel_pm={actual_vel:.4f}, ожидалось {expected_vel:.4f}. " + "Fallback должен делить на n_comp (district+class competitors)." + ) + + def test_headline_srok_is_realistic_12_to_24_months(self) -> None: + """Headline срок продажи лежит в реалистичном диапазоне 12-24 мес. + + Это ядро fix'а #574: делим market velocity на district+class competitors + (5-15 ЖК), а НЕ на region-wide ~442 ЖК. Старый (region-wide) знаменатель + давал срок 379-1180 мес — заведомо нереалистично для одного проекта. + + Setup: area_total=12000 м², n_comp=10, 3800 рыночных сделок за 24 мес. + Ожидаемый срок ≈ 18 мес. + """ + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=10, + sale_graph_vel_pm=None, + area_total_m2=12_000.0, + ) + srok = result["summary"]["months_to_sellout_total"] + assert srok is not None, "months_to_sellout_total не должен быть None" + assert 12 <= srok <= 24, ( + f"Срок продажи {srok:.1f} мес вне реалистичного диапазона 12-24. " + "При region-wide знаменателе (~442 ЖК) срок был бы ~379+ мес (баг #574)." + ) + + def test_srok_realistic_across_competitor_range(self) -> None: + """Срок остаётся в реалистичном диапазоне при n_comp 5-15.""" + # n_comp=5 (мало конкурентов) → срок ниже; n_comp=15 → выше. + # Подбираем area так чтобы оба укладывались в реалистичный коридор. + for n_comp, area, lo, hi in [ + (5, 24_000.0, 12, 24), + (15, 8_000.0, 12, 24), + ]: + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=n_comp, + sale_graph_vel_pm=None, + area_total_m2=area, + ) + srok = result["summary"]["months_to_sellout_total"] + assert srok is not None + assert lo <= srok <= hi, ( + f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" + ) + + def test_scope_has_n_competitors(self) -> None: + """scope.n_competitors присутствует и равен district+class competitors.""" + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=12, + sale_graph_vel_pm=None, + ) + assert "n_competitors" in result["scope"] + assert result["scope"]["n_competitors"] == 12 + + def test_velocity_source_is_rosreestr_fallback(self) -> None: + """velocity_source = rosreestr_fallback когда нет objective данных.""" + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=10, + sale_graph_vel_pm=None, + ) + assert result["scope"]["velocity_source"] == "rosreestr_fallback" + + +class TestPerBucketVelocityVariesByBucket: + """Bug #574 Bug_Velocity_Mix_Static: velocities per bucket — независимые константы.""" + + def test_bucket_velocities_proportional_to_market_deals(self) -> None: + """Velocity бакета пропорциональна числу РЫНОЧНЫХ сделок в этом бакете. + + Студии (710 сделок) < 1к (1306 сделок) по velocity. + """ + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=10, + sale_graph_vel_pm=None, + area_total_m2=12_000.0, + ) + buckets_by_name = {b["bucket"]: b for b in result["buckets"]} + studio_v = buckets_by_name["Студии 15-30"]["velocity_per_month"] + one_k_v = buckets_by_name["1-к 30-45"]["velocity_per_month"] + assert studio_v < one_k_v, ( + f"Студии: {studio_v:.4f} кв/мес, 1-к: {one_k_v:.4f} кв/мес. " + "1-к должны быть быстрее студий (больше сделок на рынке)." + ) + + def test_bucket_velocities_not_all_equal(self) -> None: + """Velocities бакетов не одинаковы — подтверждает исправление static mix bug.""" + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=10, + sale_graph_vel_pm=None, + area_total_m2=12_000.0, + ) + velocities = [round(b["velocity_per_month"], 6) for b in result["buckets"]] + unique_velocities = set(velocities) + assert len(unique_velocities) > 1, ( + f"Все bucket velocities одинаковые ({velocities[0]:.6f}) — " + "static mix bug не исправлен! Velocities должны отличаться." + ) + + def test_velocity_source_on_each_bucket(self) -> None: + """Каждый bucket содержит velocity_source.""" + result = _run_recommend_mix_full( + objective_per_bucket=None, + n_competitors=10, + sale_graph_vel_pm=None, + ) + for b in result["buckets"]: + assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source" + assert b["velocity_source"] in ("rosreestr_fallback", "objective_per_bucket"), ( + f"Неожиданное velocity_source='{b['velocity_source']}'" + ) + + +class TestObjectivePerBucketPath: + """Objective per-bucket path: velocities из objective_corpus_room_month.""" + + def test_objective_velocities_applied(self) -> None: + """Bucket velocities соответствуют per-bucket objective данным × macro_mult. + + sat_factor=1.0 (50% saturation), trend_factor=1.0 → macro_mult=1.0. + """ + per_bucket = { + "1-Студия": 3.5, + "2-1-к": 5.2, + "3-2-к": 4.1, + "4-3-к": 2.8, + "5-80+ м²": 1.2, + } + result = _run_recommend_mix_full( + objective_per_bucket=per_bucket, + n_competitors=10, + sale_graph_vel_pm=5.0, + ) + bkt_map = {b["bucket"]: b for b in result["buckets"]} + # Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0 + studio = bkt_map.get("Студии 15-30") + assert studio is not None + assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), ( + f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5" + ) + assert studio.get("velocity_source") == "objective_per_bucket" + + def test_objective_velocities_vary(self) -> None: + """С objective per-bucket данными скорости бакетов разные (проверяем 5 бакетов).""" + per_bucket = { + "1-Студия": 2.0, + "2-1-к": 6.0, + "3-2-к": 4.5, + "4-3-к": 3.0, + "5-80+ м²": 1.5, + } + result = _run_recommend_mix_full( + objective_per_bucket=per_bucket, + n_competitors=10, + sale_graph_vel_pm=5.0, + ) + velocities = [b["velocity_per_month"] for b in result["buckets"]] + unique = set(round(v, 4) for v in velocities) + assert len(unique) > 1, "Все objective velocities одинаковые — ошибка маппинга"