This commit is contained in:
parent
e72cf87e35
commit
6cb3d7515c
2 changed files with 695 additions and 24 deletions
|
|
@ -1706,6 +1706,95 @@ def _active_competitors_count(
|
||||||
return 1, "fallback_singleton"
|
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(
|
def _elasticity_coef(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -2315,6 +2404,9 @@ def recommend_mix(
|
||||||
sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
|
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.
|
# 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).
|
# 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"
|
velocity_source = "objective" if sale_graph_vel_pm is not None else "rosreestr_fallback"
|
||||||
|
|
||||||
elast = _elasticity_coef(
|
elast = _elasticity_coef(
|
||||||
|
|
@ -2372,32 +2464,71 @@ def recommend_mix(
|
||||||
f" нормировка по {competitors} ЖК в {scope_label}."
|
f" нормировка по {competitors} ЖК в {scope_label}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5b-2) market_vel_pm = «что продаёт ОДИН активный ЖК района за месяц».
|
# 5b-2) Per-bucket market velocity (fix #574: per-bucket formula, realistic срок).
|
||||||
# ИСТОЧНИК ИСТИНЫ — sale_graph (median realised per ЖК). При отсутствии —
|
#
|
||||||
# rosreestr-fallback: city-wide deals/mo / N_competitors → per-ЖК proxy.
|
# BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем
|
||||||
# Это критично: per-ЖК baseline должен иметь правильную размерность
|
# bucket_v = market_vel_pm × share/100 → all buckets scale proportionally →
|
||||||
# (~3-7 кв/мес для ЕКБ ЖК), иначе months_to_sellout получается
|
# 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:
|
if sale_graph_vel_pm is not None:
|
||||||
market_vel_pm = sale_graph_vel_pm
|
market_vel_pm = sale_graph_vel_pm
|
||||||
else:
|
else:
|
||||||
warnings.append(
|
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
|
||||||
"Нет sale_graph данных для этого района и класса —"
|
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
|
||||||
" темп считается по rosreestr-сделкам ÷ конкуренты (грубее)."
|
|
||||||
)
|
|
||||||
market_vel_pm = (
|
market_vel_pm = (
|
||||||
(total_deals / max(effective_window, 1) / max(competitors_weighted, 1.0))
|
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
||||||
if total_deals and competitors_weighted
|
)
|
||||||
else 0.0
|
warnings.append(
|
||||||
|
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
|
||||||
|
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5b-2.5) Per-bucket market velocity = market_vel_pm × share / 100.
|
# 5b-2.5) Per-bucket market velocity (fix #574).
|
||||||
# Аллоцируем единый per-ЖК baseline на размерные сегменты по shares
|
#
|
||||||
# (одинаковая модель для sale_graph и rosreestr_fallback). Студии/1к
|
# Objective path: используем per-bucket velocities из objective_corpus_room_month.
|
||||||
# получат больший абсолютный темп если их share высокая в районе.
|
# Для бакетов без данных в objective — fallback к rosreestr per-bucket.
|
||||||
bucket_market_velocities = {
|
# Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors).
|
||||||
b["bucket"]: market_vel_pm * (b["share_pct"] / 100.0) for b in buckets
|
# КРИТИЧНО: 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):
|
# 5b-2.5) Дополнительные district-specific signals (Tier 2):
|
||||||
# sat_factor — насколько зрелый рынок района (median sold% активных
|
# sat_factor — насколько зрелый рынок района (median sold% активных
|
||||||
|
|
@ -2436,10 +2567,9 @@ def recommend_mix(
|
||||||
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
|
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
|
||||||
|
|
||||||
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||||||
# bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК
|
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.
|
||||||
# темпа, аллоцированная на размерный сегмент.
|
# После fix #574: каждый бакет имеет независимую скорость
|
||||||
# market_vel_pm УЖЕ per-ЖК (median sale_graph либо
|
# (не производную от share) → mix-слайдеры реально меняют KPI.
|
||||||
# rosreestr/N_competitors), доп. нормировка не нужна.
|
|
||||||
# project_velocity = bucket_market_v × sat_factor × trend_factor
|
# project_velocity = bucket_market_v × sat_factor × trend_factor
|
||||||
# sat — зрелый рынок ускоряет; trend — текущая
|
# sat — зрелый рынок ускоряет; trend — текущая
|
||||||
# динамика (горит/остывает).
|
# динамика (горит/остывает).
|
||||||
|
|
@ -2454,6 +2584,14 @@ def recommend_mix(
|
||||||
total_units = 0
|
total_units = 0
|
||||||
for b in buckets:
|
for b in buckets:
|
||||||
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
|
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)
|
bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4)
|
||||||
b["velocity_per_month"] = bucket_velocity
|
b["velocity_per_month"] = bucket_velocity
|
||||||
# Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty).
|
# 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
|
round(market_vel_pm, 3) if market_vel_pm is not None else None
|
||||||
),
|
),
|
||||||
"velocity_source": velocity_source,
|
"velocity_source": velocity_source,
|
||||||
|
# fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback
|
||||||
|
"n_competitors": n_comp,
|
||||||
"velocity_observations": vel["observations"],
|
"velocity_observations": vel["observations"],
|
||||||
"velocity_objects": vel["objects_count"],
|
"velocity_objects": vel["objects_count"],
|
||||||
"competitors_count": competitors,
|
"competitors_count": competitors,
|
||||||
|
|
|
||||||
531
backend/tests/services/test_recommend_mix_velocity.py
Normal file
531
backend/tests/services/test_recommend_mix_velocity.py
Normal file
|
|
@ -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 одинаковые — ошибка маппинга"
|
||||||
Loading…
Add table
Reference in a new issue