gendesign/backend/tests/services/test_recommend_mix_velocity.py
Light1YT eef240a17f feat(recommend): horizon-aware forecast-overlay for recommend_mix (#982, 953-A)
Additive opt-in: when horizon_months is passed, attach an ADVISORY forecast
overlay under scope["forecast"] via new forecasting/recommendation.py
(demand_supply when cad_num set → #981 rank_segments; demand_only otherwise →
per-room-bucket demand pace, NO fabricated supply). Default path stays
byte-identical — overlay is try/except-guarded and never crashes the live mix.

- schemas/recommend.py: horizon_months + cad_num inputs; RecommendForecast{Segment,
  Overlay} output models (ride open scope dict; RecommendMixOutput 4 fields intact)
- forecasting/recommendation.py: pure room/class bridges (inverse of _BUCKET_PRETTY,
  drift-guarded) + build_forecast_overlay (graceful, advisory always True)
- analytics_queries.recommend_mix: +horizon_months/cad_num params + guarded tail
- api/v1/analytics.py: endpoint passthrough
- tests: bridges both directions, both modes, graceful, invariance, crash isolation

NOTE: live recommend endpoint now has a DORMANT path into the advisory forecast
engine (frontend sends no horizon_months yet); wiring point when #951 backtest lands.
45 tests; 413 forecasting+velocity green. opus code-review  (7 live-endpoint gates).
2026-06-03 12:28:00 +05:00

621 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
horizon_months: int | None = None,
cad_num: str | None = None,
) -> dict[str, Any]:
"""Запускает recommend_mix с правильным набором моков.
n_competitors — district+class competitors_weighted, который возвращает
_competitors_two_dim и используется как знаменатель в rosreestr fallback.
horizon_months/cad_num — #982 forecast-overlay opt-in (по умолчанию None →
живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты).
"""
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,
horizon_months=horizon_months,
cad_num=cad_num,
)
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 одинаковые — ошибка маппинга"
_FORECAST_PER_BUCKET = {
"1-Студия": 2.0,
"2-1-к": 6.0,
"3-2-к": 4.5,
"4-3-к": 3.0,
"5-80+ м²": 1.5,
}
class TestForecastOverlayOptIn:
"""#982 (953-A): forecast-overlay — ADDITIVE OPT-IN + live-endpoint safety."""
def test_invariance_no_horizon_no_forecast_key(self) -> None:
"""horizon_months=None → НЕТ scope['forecast'] (живой микс не тронут)."""
result = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET,
n_competitors=10,
sale_graph_vel_pm=5.0,
)
assert "forecast" not in result["scope"]
def test_invariance_top_level_keys_unchanged(self) -> None:
"""Топ-уровневые ключи ответа неизменны (4 поля контракта) без horizon."""
result = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET,
n_competitors=10,
sale_graph_vel_pm=5.0,
)
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}
def test_invariance_byte_identical_scope_when_off(self) -> None:
"""scope БАЙТ-в-БАЙТ совпадает с/без передачи horizon_months=None."""
base = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0
)
again = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET,
n_competitors=10,
sale_graph_vel_pm=5.0,
horizon_months=None,
)
assert base["scope"] == again["scope"]
assert base["buckets"] == again["buckets"]
assert base["summary"] == again["summary"]
def test_opt_in_attaches_overlay_under_scope(self) -> None:
"""horizon_months задан → scope['forecast'] = результат build_forecast_overlay."""
sentinel = {"horizon_months": 12, "mode": "demand_only", "advisory": True}
with patch(
"app.services.forecasting.recommendation.build_forecast_overlay",
return_value=sentinel,
) as mock_overlay:
result = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET,
n_competitors=10,
sale_graph_vel_pm=5.0,
horizon_months=12,
)
assert result["scope"]["forecast"] is sentinel
assert mock_overlay.call_count == 1
# district проброшен (resolved district_name), horizon/cad — из payload.
assert mock_overlay.call_args.kwargs["horizon_months"] == 12
assert mock_overlay.call_args.kwargs["cad_num"] is None
def test_crash_isolation_overlay_failure_does_not_break_live_mix(self) -> None:
"""build_forecast_overlay бросает → живой микс ВСЁ РАВНО возвращается."""
with patch(
"app.services.forecasting.recommendation.build_forecast_overlay",
side_effect=RuntimeError("boom"),
):
result = _run_recommend_mix_full(
objective_per_bucket=_FORECAST_PER_BUCKET,
n_competitors=10,
sale_graph_vel_pm=5.0,
horizon_months=12,
)
# Микс жив; overlay = {error, advisory}, остальные поля на месте.
assert result["buckets"]
assert result["scope"]["forecast"]["advisory"] is True
assert "boom" in result["scope"]["forecast"]["error"]
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}