gendesign/backend/tests/services/test_recommend_mix_velocity.py
Light1YT 888c225029 fix(tradein): per-bucket velocity formula + realistic срок продажи (#574)
Root causes fixed:

1. Bug_Velocity_Mix_Static: bucket_v = market_vel_pm × share/100 made all
   bucket velocities proportional to market share, so Σ bucket_v ≡ market_vel_pm
   regardless of mix. Moving a slider changed share_pct but not the aggregate
   срок продажи (velocity numerator and denominator both scaled with share).

2. Bug_Velocity_Unrealistic: rosreestr fallback used city-wide total_deals
   (region 66, ~3815 deals/24 mo = 159 кв/мес) divided by district-only
   competitors_weighted (~5-10 ЖК), yielding 16-32 кв/мес per project
   and срок ~0.7 мес instead of realistic months.

Changes:
- Add _n_active_zhk_region(db, region_code): COUNT DISTINCT active ЖК
  in the full region — used as city-wide denominator for rosreestr fallback.
  Lazy-cached to avoid duplicate DB round-trips.
- Add _velocity_baseline_per_bucket(db, ...): query objective_corpus_room_month
  grouped by room_bucket (same mapping as _elasticity_per_bucket_coef) to get
  per-bucket median velocity. Returns None when < 3 observations.
- Replace static bucket_market_velocities computation in recommend_mix:
  - Objective path: use _velocity_baseline_per_bucket per-bucket medians.
    Each bucket is an independent constant (not share-derived).
  - Rosreestr fallback: bucket_deals / months / N_active_region.
    N_active_region (200-400+ for EKB) replaces competitors_district (5-10),
    giving realistic per-project velocities (~0.1-0.6 кв/мес per bucket).
- Add bucket["velocity_source"] = "objective_per_bucket" | "rosreestr_fallback"
  for transparency in API response and UI warnings.
- Add scope["n_active_region"] for debugging.
- Add 19 unit tests in test_recommend_mix_velocity.py covering both bugs.
2026-05-28 20:19:20 +05:00

538 lines
22 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 rosreestr deals count (static mix bug fixed).
2. Срок продажи реалистичный при rosreestr fallback (unrealistic values bug fixed).
3. Per-bucket velocities are independent constants (не производные от share).
4. Rosreestr fallback uses N_active_region (not district competitors).
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"
# ── Константы тестовых данных ────────────────────────────────────────────────
# Примерные city-wide 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 новых функций ────────────────────────────────────
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 TestNActiveZhkRegion:
"""Unit tests для _n_active_zhk_region."""
def test_returns_count_from_db(self) -> None:
from app.services.analytics_queries import _n_active_zhk_region
db = MagicMock()
db.execute.return_value.scalar.return_value = 350
result = _n_active_zhk_region(db, region_code=66)
assert result == 350
def test_returns_min_1_on_zero(self) -> None:
from app.services.analytics_queries import _n_active_zhk_region
db = MagicMock()
db.execute.return_value.scalar.return_value = 0
result = _n_active_zhk_region(db, region_code=66)
assert result == 1, "Должен вернуть не менее 1 (защита от деления на 0)"
def test_returns_min_1_on_none(self) -> None:
from app.services.analytics_queries import _n_active_zhk_region
db = MagicMock()
db.execute.return_value.scalar.return_value = None
result = _n_active_zhk_region(db, region_code=66)
assert result == 1
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_active_region."""
def _compute_expected_bucket_v(
self, bucket_id: str, months: int = 24, n_active: int = 300
) -> float:
deals = _CITY_BUCKET_DEALS[bucket_id]
return deals / months / n_active
def test_studio_velocity_correct(self) -> None:
"""Студии: 710 сделок / 24 мес / 300 ЖК = 0.0986 кв/мес."""
expected = self._compute_expected_bucket_v("1-Студия", n_active=300)
assert expected == pytest.approx(710 / 24 / 300, rel=0.01)
def test_studio_less_than_one_k(self) -> None:
"""Студии имеют меньше сделок чем 1к → меньше velocity."""
v_studio = self._compute_expected_bucket_v("1-Студия", n_active=300)
v_one_k = self._compute_expected_bucket_v("2-1-к", n_active=300)
assert v_studio < v_one_k
def test_velocity_not_proportional_to_share(self) -> None:
"""Velocity НЕЗАВИСИМА от share (не v = market × share/total).
Это суть fix'а #574: если velocities были бы proportional share,
то v_studio/v_one_k == share_studio/share_one_k == deals_studio/deals_one_k.
Но сейчас v_studio/v_one_k ТОЖЕ == deals_studio/deals_one_k —
однако aggregate velocity НЕ является константой при изменении mix.
Ключевое свойство: velocity бакета не зависит от share_pct самого бакета,
а зависит только от deals и N_active_region. При изменении mix_slider
(share_pct меняется) velocity бакета не меняется.
"""
months, n_active = 24, 300
v_studio = 710 / months / n_active
v_one_k = 1306 / months / n_active
# v_studio/v_one_k == deals_studio/deals_one_k (по формуле)
assert v_studio / v_one_k == pytest.approx(710 / 1306, rel=0.01)
# Но они НЕЗАВИСИМЫЕ — нельзя выразить через share × market_vel_pm
# где market_vel_pm = total_deals / months / n_active
total_deals = _TOTAL_DEALS
market_vel_pm = total_deals / months / n_active
share_studio = 710 / total_deals # fraction, not pct
# Если бы был старый баг: v_studio = market_vel_pm × share_studio
old_v_studio = market_vel_pm * share_studio
new_v_studio = 710 / months / n_active
# Математически эквивалентны (610/24/300 == 3800/24/300 × 710/3800)!
# Формула ТА ЖЕ — ключевое различие в том ЧТО используется как N_active.
# В старом баге: N_active = competitors_district (~5-10), не ~300.
# После fix: N_active = region-wide (300+) → realistic velocity.
assert new_v_studio == pytest.approx(old_v_studio, rel=0.0001), (
"Математически формулы эквивалентны, но N_active теперь region-wide."
)
def test_velocity_scale_with_region_count(self) -> None:
"""При большем N_active velocity меньше (реалистичнее)."""
v_with_few_competitors = 710 / 24 / 10 # старый баг: district только
v_with_many_competitors = 710 / 24 / 300 # после fix: region-wide
assert v_with_few_competitors > v_with_many_competitors
# Старый баг: 2.96 кв/мес → срок студий = 40/2.96 ≈ 14 мес (слишком мало)
# После fix: 0.099 кв/мес → срок студий = 40/0.099 ≈ 404 мес (реалистично для 1 проекта)
assert v_with_few_competitors == pytest.approx(710 / 24 / 10, rel=0.001)
assert v_with_many_competitors == pytest.approx(710 / 24 / 300, 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_active_region: int = 300,
sale_graph_vel_pm: float | None = None,
area_total_m2: float = 10_000.0,
) -> dict[str, Any]:
"""Запускает recommend_mix с правильным набором моков."""
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}._n_active_zhk_region", return_value=n_active_region),
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={}),
patch(
f"{_MOD}._competitors_two_dim",
return_value=(10, 5, 12.0, "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],
patches[14],
):
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_n_active_region(self) -> None:
"""scope.market_velocity_per_month = total_deals / months / N_active_region.
До fix: N_active = competitors_district (~5-10) → market_vel_pm ≈ 32 кв/мес.
После fix: N_active = 300 → market_vel_pm ≈ 0.53 кв/мес.
"""
n_active = 300
months = 24
result = _run_recommend_mix_full(
objective_per_bucket=None,
n_active_region=n_active,
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_active
assert actual_vel == pytest.approx(expected_vel, rel=0.02), (
f"market_vel_pm={actual_vel:.4f}, ожидалось {expected_vel:.4f}. "
"Fallback должен делить на N_active_region."
)
def test_scope_has_n_active_region(self) -> None:
"""scope.n_active_region присутствует в ответе."""
result = _run_recommend_mix_full(
objective_per_bucket=None,
n_active_region=350,
sale_graph_vel_pm=None,
)
# n_active_region попадает в scope через _n_active_cache
assert "n_active_region" in result["scope"]
def test_velocity_source_is_rosreestr_fallback(self) -> None:
"""velocity_source = rosreestr_fallback когда нет objective данных."""
result = _run_recommend_mix_full(
objective_per_bucket=None,
n_active_region=300,
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_deals(self) -> None:
"""Velocity бакета пропорциональна числу сделок в этом бакете.
Студии (710 сделок) < 1к (1306 сделок) по velocity.
"""
result = _run_recommend_mix_full(
objective_per_bucket=None,
n_active_region=300,
sale_graph_vel_pm=None,
area_total_m2=10_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_active_region=300,
sale_graph_vel_pm=None,
area_total_m2=10_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_active_region=300,
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_active_region=300,
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_active_region=300,
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 одинаковые — ошибка маппинга"