gendesign/backend/tests/services/forecasting/test_special_indices.py
bot-backend 008f9d9ed1
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m28s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m8s
feat(forecasting): §25 six special indices (#986, 954-C) (#1018)
2026-06-03 08:24:08 +00:00

879 lines
38 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.

"""Тесты §25 special_indices (#986/954-C) — pure-helpers + mocked-БД + оркестратор.
Покрытие:
• Pure-математика БЕЗ БД: Product Void (порог/доля/счёт), Launch Window (пик +
tie-break + нормализация), Competitor Strength (топ-N среднее), Cannibalization
(доля same-class), Artificial Demand (доля ипотеки), Cost-of-Error (монотонность
+ произведение), нормализации в [0,1], confidence-helpers, _avg_ticket.
• Artificial-Demand SQL: MagicMock-сессия — форма SQL (CAST(:x AS type), не ::),
параметры; сигнал есть → индекс; нет проданных → None + caveat.
• compute_special_indices: @patch бэкенд-сервисов — 6 индексов присутствуют,
advisory True, per-index graceful (сбой одного → unavailable, карточка цела).
Всё детерминировано, без БД (сессия мокается). Зеркалит стиль test_what_to_build /
test_market_metrics.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.services.forecasting.sales_series import SegmentSpec
from app.services.forecasting.special_indices import (
_INDEX_KEYS,
_METHOD_UNAVAILABLE,
KEY_ARTIFICIAL_DEMAND,
KEY_CANNIBALIZATION,
KEY_COMPETITOR_STRENGTH,
KEY_COST_OF_ERROR,
KEY_LAUNCH_WINDOW,
KEY_PRODUCT_VOID,
_artificial_demand_share,
_avg_ticket_rub,
_cannibalization_index,
_cap_confidence,
_clamp01,
_competitor_strength,
_cost_of_error_index,
_count_void,
_min_confidence,
_oversupply_risk_from_deficit,
_pick_launch_window,
_query_artificial_demand,
_void_index,
compute_special_indices,
)
# Patch-таргеты — имена, импортированные В модуль special_indices. _DSF используется
# точечно (re-patch поверх _full_stack_patch); остальные сервисы патчатся через
# patch.multiple(_MOD, ...) по kwargs-имени, поэтому отдельных констант им не нужно.
_MOD = "app.services.forecasting.special_indices"
_DSF = f"{_MOD}.compute_demand_supply_forecast"
# ──────────────────────────────────────────────────────────────────────────────
# Pure: _clamp01 / confidence-helpers
# ──────────────────────────────────────────────────────────────────────────────
class TestClamp01:
def test_in_range_passthrough(self) -> None:
assert _clamp01(0.4) == 0.4
def test_above_one_clamped(self) -> None:
assert _clamp01(1.7) == 1.0
def test_below_zero_clamped(self) -> None:
assert _clamp01(-0.3) == 0.0
class TestCapConfidence:
def test_high_capped_to_medium(self) -> None:
assert _cap_confidence("high") == "medium"
def test_medium_unchanged(self) -> None:
assert _cap_confidence("medium") == "medium"
def test_low_unchanged(self) -> None:
assert _cap_confidence("low") == "low"
class TestMinConfidence:
def test_worst_wins(self) -> None:
assert _min_confidence(["high", "low", "medium"]) == "low"
def test_none_ignored(self) -> None:
assert _min_confidence(["medium", None, "high"]) == "medium"
def test_all_none_low(self) -> None:
assert _min_confidence([None, None]) == "low"
def test_empty_low(self) -> None:
assert _min_confidence([]) == "low"
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Product Void (порог / доля / счёт)
# ──────────────────────────────────────────────────────────────────────────────
class TestVoidIndex:
def test_share_above_threshold(self) -> None:
# 2 из 4 измеренных ≥ 0.25 → 0.5.
assert _void_index([0.3, 0.1, 0.9, -0.2]) == 0.5
def test_threshold_is_inclusive(self) -> None:
# ровно на пороге 0.25 → считается пустотой.
assert _void_index([0.25]) == 1.0
def test_below_threshold_excluded(self) -> None:
# 0.24 < 0.25 → не пустота.
assert _void_index([0.24]) == 0.0
def test_none_cells_excluded_from_denominator(self) -> None:
# None не входит ни в числитель, ни в знаменатель: 1 из 2 измеренных.
assert _void_index([0.9, None, 0.0]) == 0.5
def test_empty_yields_zero(self) -> None:
assert _void_index([]) == 0.0
def test_all_none_yields_zero(self) -> None:
assert _void_index([None, None]) == 0.0
def test_in_range(self) -> None:
v = _void_index([0.9, 0.8, 0.7])
assert 0.0 <= v <= 1.0
assert v == 1.0
def test_custom_threshold(self) -> None:
assert _void_index([0.5, 0.4], threshold=0.45) == 0.5
class TestCountVoid:
def test_counts_at_or_above_threshold(self) -> None:
assert _count_void([0.3, 0.25, 0.1, 0.99]) == 3
def test_none_skipped(self) -> None:
assert _count_void([None, 0.9, None]) == 1
def test_empty_zero(self) -> None:
assert _count_void([]) == 0
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Launch Window (пик + tie-break + нормализация)
# ──────────────────────────────────────────────────────────────────────────────
class TestPickLaunchWindow:
def test_picks_peak_horizon(self) -> None:
h, strength = _pick_launch_window({6: 0.1, 12: 0.4, 18: 0.2, 24: -0.1})
assert h == 12
# 0.4 / 0.5 (saturation) = 0.8.
assert strength == pytest.approx(0.8)
def test_strength_in_range(self) -> None:
_, strength = _pick_launch_window({6: 0.9, 12: 0.95})
assert strength is not None
assert 0.0 <= strength <= 1.0
assert strength == 1.0 # 0.95/0.5 clamp → 1.0
def test_tie_break_prefers_earlier_horizon(self) -> None:
# равный пиковый deficit на 6 и 18 → берём меньший горизонт (раньше выгоднее).
h, _ = _pick_launch_window({6: 0.5, 18: 0.5})
assert h == 6
def test_none_horizons_ignored(self) -> None:
# 12 — None (тонко), пик среди измеренных = 24.
h, _ = _pick_launch_window({6: 0.1, 12: None, 24: 0.3})
assert h == 24
def test_all_none_degrades(self) -> None:
assert _pick_launch_window({6: None, 12: None}) == (None, None)
def test_empty_degrades(self) -> None:
assert _pick_launch_window({}) == (None, None)
def test_nonpositive_peak_zero_strength_but_horizon_returned(self) -> None:
# все горизонты ≤0 (баланс/затоварка): окна «строить» нет (strength 0),
# но аргмакс-горизонт возвращаем (наименее плохой) для explainability.
h, strength = _pick_launch_window({6: -0.3, 12: -0.1, 24: -0.5})
assert h == 12 # наибольший (наименее отрицательный) deficit
assert strength == 0.0
def test_saturation_nonpositive_degrades_to_sign(self) -> None:
h, strength = _pick_launch_window({6: 0.2}, saturation=0.0)
assert h == 6
assert strength == 1.0
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Competitor Strength (топ-N среднее)
# ──────────────────────────────────────────────────────────────────────────────
class TestCompetitorStrength:
def test_mean_of_top_n(self) -> None:
# топ-5 по убыванию из 6: 0.9,0.8,0.7,0.6,0.5 → mean 0.7.
v = _competitor_strength([0.1, 0.5, 0.9, 0.6, 0.8, 0.7])
assert v == pytest.approx(0.7)
def test_fewer_than_top_n(self) -> None:
v = _competitor_strength([0.4, 0.6])
assert v == pytest.approx(0.5)
def test_none_weights_skipped(self) -> None:
v = _competitor_strength([None, 0.8, None, 0.4])
assert v == pytest.approx(0.6)
def test_empty_is_none(self) -> None:
assert _competitor_strength([]) is None
def test_all_none_is_none(self) -> None:
assert _competitor_strength([None, None]) is None
def test_in_range(self) -> None:
v = _competitor_strength([0.9, 0.95, 1.0])
assert v is not None and 0.0 <= v <= 1.0
def test_custom_top_n(self) -> None:
v = _competitor_strength([0.9, 0.8, 0.1], top_n=2)
assert v == pytest.approx(0.85)
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Cannibalization (доля same-class)
# ──────────────────────────────────────────────────────────────────────────────
class TestCannibalizationIndex:
def test_share_of_same_class(self) -> None:
# same = 0.6+0.4 = 1.0; all = 0.6+0.4+0.5+0.5 = 2.0 → 0.5.
v = _cannibalization_index([0.6, 0.4], [0.6, 0.4, 0.5, 0.5])
assert v == pytest.approx(0.5)
def test_no_same_class_is_zero(self) -> None:
# конкуренты есть, но ни одного в нашем классе → 0.0 (валидно).
v = _cannibalization_index([], [0.5, 0.5])
assert v == 0.0
def test_no_competitors_is_none(self) -> None:
# окружения нет вообще → None (неизмеримо).
assert _cannibalization_index([], []) is None
def test_all_same_class_is_one(self) -> None:
v = _cannibalization_index([0.5, 0.5], [0.5, 0.5])
assert v == 1.0
def test_none_weights_skipped(self) -> None:
v = _cannibalization_index([0.5], [0.5, None, 0.5])
assert v == pytest.approx(0.5)
def test_in_range(self) -> None:
v = _cannibalization_index([0.3], [0.3, 0.7])
assert v is not None and 0.0 <= v <= 1.0
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Artificial Demand (доля ипотеки)
# ──────────────────────────────────────────────────────────────────────────────
class TestArtificialDemandShare:
def test_basic_share(self) -> None:
assert _artificial_demand_share(30, 100) == pytest.approx(0.3)
def test_full_mortgage(self) -> None:
assert _artificial_demand_share(50, 50) == 1.0
def test_zero_mortgage(self) -> None:
assert _artificial_demand_share(0, 80) == 0.0
def test_no_sold_is_none(self) -> None:
# нет проданных лотов → None (НЕ фабрикуем 0).
assert _artificial_demand_share(0, 0) is None
def test_none_mortgage_is_none(self) -> None:
assert _artificial_demand_share(None, 50) is None
def test_clamped_to_one_on_dirty_data(self) -> None:
# грязные данные (mortgage > sold) → clamp 1.0, не >1.
assert _artificial_demand_share(120, 100) == 1.0
def test_in_range(self) -> None:
v = _artificial_demand_share(17, 23)
assert v is not None and 0.0 <= v <= 1.0
# ──────────────────────────────────────────────────────────────────────────────
# Pure: Cost-of-Error (монотонность + произведение + чек)
# ──────────────────────────────────────────────────────────────────────────────
class TestAvgTicket:
def test_price_times_area(self) -> None:
# 200_000 ₽/м² × 50 м² = 10 млн.
assert _avg_ticket_rub(200_000.0) == 10_000_000.0
def test_none_price_is_none(self) -> None:
assert _avg_ticket_rub(None) is None
def test_nonpositive_price_is_none(self) -> None:
assert _avg_ticket_rub(0.0) is None
def test_custom_area(self) -> None:
assert _avg_ticket_rub(100_000.0, area_m2=80.0) == 8_000_000.0
class TestOversupplyRiskFromDeficit:
def test_negative_deficit_is_magnitude(self) -> None:
assert _oversupply_risk_from_deficit(-0.4) == pytest.approx(0.4)
def test_positive_deficit_zero_risk(self) -> None:
assert _oversupply_risk_from_deficit(0.6) == 0.0
def test_zero_deficit_zero_risk(self) -> None:
assert _oversupply_risk_from_deficit(0.0) == 0.0
def test_none_is_none(self) -> None:
assert _oversupply_risk_from_deficit(None) is None
class TestCostOfErrorIndex:
def test_product_of_risk_and_ticket(self) -> None:
# risk 0.5 × (10млн/15млн clamp=0.6667) ≈ 0.3333.
v = _cost_of_error_index(0.5, 10_000_000.0)
assert v == pytest.approx(0.5 * (10_000_000.0 / 15_000_000.0))
def test_monotonic_in_risk(self) -> None:
low = _cost_of_error_index(0.2, 10_000_000.0)
high = _cost_of_error_index(0.8, 10_000_000.0)
assert low is not None and high is not None
assert high > low
def test_monotonic_in_ticket(self) -> None:
cheap = _cost_of_error_index(0.5, 5_000_000.0)
pricey = _cost_of_error_index(0.5, 12_000_000.0)
assert cheap is not None and pricey is not None
assert pricey > cheap
def test_in_range(self) -> None:
v = _cost_of_error_index(0.9, 30_000_000.0) # ticket clamps to 1.0
assert v is not None and 0.0 <= v <= 1.0
assert v == pytest.approx(0.9)
def test_none_risk_is_none(self) -> None:
assert _cost_of_error_index(None, 10_000_000.0) is None
def test_none_ticket_is_none(self) -> None:
assert _cost_of_error_index(0.5, None) is None
def test_nonpositive_norm_degrades_to_risk_only(self) -> None:
v = _cost_of_error_index(0.4, 10_000_000.0, ticket_norm_rub=0.0)
assert v == pytest.approx(0.4)
# ──────────────────────────────────────────────────────────────────────────────
# Artificial-Demand SQL: MagicMock-сессия (форма SQL + параметры + сигнал/деградация)
# ──────────────────────────────────────────────────────────────────────────────
def _mock_db_one(row: dict[str, Any] | None) -> MagicMock:
"""Сессия, чей единственный execute().mappings().first() вернёт row."""
db = MagicMock()
result = MagicMock()
result.mappings.return_value.first.return_value = row
db.execute.return_value = result
return db
def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
args, _ = db.execute.call_args_list[call_index]
return str(args[0])
def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]:
args, _ = db.execute.call_args_list[call_index]
return args[1]
class TestArtificialDemandSQL:
def test_sql_uses_cast_not_double_colon(self) -> None:
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
_query_artificial_demand(
db, district="Академический", obj_class="комфорт", premise_kind="квартира"
)
sql = _executed_sql(db)
assert "CAST(:premise_kind AS text)" in sql
assert "CAST(:district AS text)" in sql
assert "CAST(:obj_class AS text)" in sql
# psycopg v3: никогда :x::type
assert ":premise_kind::" not in sql
assert ":district::" not in sql
assert ":obj_class::" not in sql
def test_sql_reads_mortgage_signal_columns(self) -> None:
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
sql = _executed_sql(db)
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
assert "encumbrance_type" in sql
assert "bank_name" in sql
assert "objective_lots" in sql
def test_params_passed(self) -> None:
db = _mock_db_one({"n_sold": 1, "n_mortgage": 0})
_query_artificial_demand(
db, district="Пионерский", obj_class="бизнес", premise_kind="квартира"
)
p = _executed_params(db)
assert p["district"] == "Пионерский"
assert p["obj_class"] == "бизнес"
assert p["premise_kind"] == "квартира"
def test_signal_present_returns_counts(self) -> None:
db = _mock_db_one({"n_sold": 80, "n_mortgage": 52})
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
assert out == {"n_sold": 80, "n_mortgage": 52}
def test_empty_row_degrades_to_zeros(self) -> None:
db = _mock_db_one(None)
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
assert out == {"n_sold": 0, "n_mortgage": 0}
def test_null_counts_coerced_to_zero(self) -> None:
db = _mock_db_one({"n_sold": None, "n_mortgage": None})
out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
assert out == {"n_sold": 0, "n_mortgage": 0}
# ──────────────────────────────────────────────────────────────────────────────
# Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat
# ──────────────────────────────────────────────────────────────────────────────
_SPEC = SegmentSpec(obj_class="комфорт", room_bucket="2-к 45-60", district="Академический")
def _patch_all_unavailable() -> Any:
"""Контекст: все 5 НЕ-Artificial бэкенда брошены/пусты (изолируем Artificial)."""
return patch.multiple(
_MOD,
compute_demand_supply_forecast=MagicMock(return_value=[]),
rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
get_competitors=MagicMock(side_effect=ValueError("no geom")),
compute_market_metrics=MagicMock(
return_value=MagicMock(overstock_index=None, confidence="low")
),
compute_affordability=MagicMock(return_value=MagicMock(price_per_m2=None)),
)
class TestArtificialDemandBuilder:
def test_signal_present_yields_index(self) -> None:
db = MagicMock()
with (
_patch_all_unavailable(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 65},
),
):
card = compute_special_indices(db, spec=_SPEC, district="Академический")
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
assert idx.value == pytest.approx(0.65)
assert idx.method == "mortgage_share_objective_lots"
assert idx.detail["n_sold"] == 100
assert idx.detail["n_mortgage"] == 65
assert idx.advisory is True
def test_no_signal_yields_none_with_caveat(self) -> None:
db = MagicMock()
with (
_patch_all_unavailable(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 0, "n_mortgage": 0},
),
):
card = compute_special_indices(db, spec=_SPEC, district="Академический")
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
assert idx.value is None
assert idx.method == _METHOD_UNAVAILABLE
assert "reason" in idx.detail
assert "фабрикуем" in idx.detail["reason"] # явный caveat «не фабрикуем»
def test_small_sample_low_confidence(self) -> None:
db = MagicMock()
with (
_patch_all_unavailable(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 5, "n_mortgage": 3},
),
):
card = compute_special_indices(db, spec=_SPEC, district="Академический")
idx = card.indices[KEY_ARTIFICIAL_DEMAND]
assert idx.value == pytest.approx(0.6)
assert idx.confidence == "low" # n_sold < 30
# ──────────────────────────────────────────────────────────────────────────────
# compute_special_indices: @patch бэкенда — 6 индексов, advisory, graceful per-index
# ──────────────────────────────────────────────────────────────────────────────
def _forecast_stub(deficit: float | None, *, horizon: int, confidence: str = "medium") -> MagicMock:
f = MagicMock()
f.deficit_index = deficit
f.horizon_months = horizon
f.confidence = confidence
return f
def _ranked_stub(deficit: float, *, confidence: str = "medium") -> MagicMock:
seg = MagicMock()
seg.deficit_index = deficit
seg.confidence = confidence
seg.as_dict.return_value = {"deficit_index": deficit}
return seg
def _competitor_stub(relevance: float | None, obj_class: str | None) -> MagicMock:
c = MagicMock()
c.relevance_weight = relevance
c.obj_class = obj_class
return c
def _full_stack_patch() -> Any:
"""Все 5 бэкенд-сервисов отдают здоровые данные → все 6 индексов считаются."""
forecasts = [_forecast_stub(0.1 * h, horizon=h) for h in (6, 12, 18, 24)]
ranking = MagicMock()
ranking.ranked = [_ranked_stub(0.5), _ranked_stub(0.3), _ranked_stub(-0.1)]
ranking.n_cells_ranked = 3
competitors_resp = MagicMock()
competitors_resp.competitors = [
_competitor_stub(0.8, "комфорт"),
_competitor_stub(0.6, "комфорт"),
_competitor_stub(0.5, "бизнес"),
]
metrics = MagicMock()
metrics.overstock_index = 0.4
metrics.confidence = "medium"
afford = MagicMock()
afford.price_per_m2 = 200_000.0
return patch.multiple(
_MOD,
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
rank_segments=MagicMock(return_value=ranking),
get_competitors=MagicMock(return_value=competitors_resp),
compute_market_metrics=MagicMock(return_value=metrics),
compute_affordability=MagicMock(return_value=afford),
)
class TestComputeSpecialIndicesShape:
def test_all_six_indices_present(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
assert set(card.indices.keys()) == set(_INDEX_KEYS)
assert len(card.indices) == 6
def test_advisory_always_true(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
assert card.advisory is True
assert all(idx.advisory is True for idx in card.indices.values())
def test_all_values_in_range_when_present(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
for idx in card.indices.values():
if idx.value is not None:
assert 0.0 <= idx.value <= 1.0, idx.key
def test_confidence_capped_at_medium(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
assert card.confidence in ("low", "medium") # никогда high
for idx in card.indices.values():
assert idx.confidence in ("low", "medium")
def test_as_dict_roundtrip(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
d = card.as_dict()
assert d["advisory"] is True
assert set(d["indices"].keys()) == set(_INDEX_KEYS)
assert d["district"] == "Академический"
# каждый индекс сериализуется с ключами контракта.
for sub in d["indices"].values():
assert set(sub.keys()) == {
"key",
"value",
"label",
"confidence",
"detail",
"method",
"advisory",
}
class TestComputeSpecialIndicesValues:
def test_launch_window_picks_peak_horizon(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
lw = card.indices[KEY_LAUNCH_WINDOW]
# deficit = 0.1*h → пик на h=24 (0.1*24=2.4 clamps), label «24 мес».
assert lw.label == "24 мес"
assert lw.detail["best_horizon_months"] == 24
def test_product_void_counts_segments(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
pv = card.indices[KEY_PRODUCT_VOID]
# ranked deficits [0.5,0.3,-0.1]: 2 ≥ 0.25 → share 2/3.
assert pv.value == pytest.approx(2 / 3)
assert pv.detail["n_void"] == 2
def test_competitor_strength_mean_top_n(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
cs = card.indices[KEY_COMPETITOR_STRENGTH]
# relevance [0.8,0.6,0.5] → mean 0.6333.
assert cs.value == pytest.approx((0.8 + 0.6 + 0.5) / 3)
def test_cannibalization_same_class_share(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
can = card.indices[KEY_CANNIBALIZATION]
# same-class (комфорт) = 0.8+0.6=1.4; all=1.9 → 0.7368.
assert can.value == pytest.approx((0.8 + 0.6) / (0.8 + 0.6 + 0.5))
def test_cost_of_error_product(self) -> None:
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
coe = card.indices[KEY_COST_OF_ERROR]
# risk 0.4 × ticket(200k×50=10млн / 15млн = 0.6667) ≈ 0.2667.
ticket_factor = (200_000.0 * 50.0) / 15_000_000.0
assert coe.value == pytest.approx(0.4 * ticket_factor)
assert coe.detail["risk_source"] == "overstock_index"
class TestComputeSpecialIndicesGraceful:
def test_no_cad_num_degrades_competitor_indices(self) -> None:
# без cad_num: Cannibalization + Competitor Strength → unavailable, остальные ок.
db = MagicMock()
with (
_full_stack_patch(),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(db, spec=_SPEC, district="Академический")
assert card.indices[KEY_CANNIBALIZATION].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_COMPETITOR_STRENGTH].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_CANNIBALIZATION].value is None
# quick-win Artificial Demand всё равно посчитан.
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)
def test_per_index_failure_isolated(self) -> None:
# один сервис (rank_segments) бросает → Product Void unavailable, остальные 5 ок.
forecasts = [_forecast_stub(0.2, horizon=h) for h in (6, 12, 18, 24)]
metrics = MagicMock(overstock_index=0.3, confidence="medium")
afford = MagicMock(price_per_m2=180_000.0)
comp = MagicMock()
comp.competitors = [_competitor_stub(0.7, "комфорт")]
db = MagicMock()
with (
patch.multiple(
_MOD,
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
rank_segments=MagicMock(side_effect=RuntimeError("boom")),
get_competitors=MagicMock(return_value=comp),
compute_market_metrics=MagicMock(return_value=metrics),
compute_affordability=MagicMock(return_value=afford),
),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 50, "n_mortgage": 20},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
# сбойный индекс — unavailable; карточка цела (все 6 ключей).
assert card.indices[KEY_PRODUCT_VOID].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_PRODUCT_VOID].value is None
assert len(card.indices) == 6
# остальные посчитаны.
assert card.indices[KEY_LAUNCH_WINDOW].value is not None
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.4)
assert card.advisory is True
def test_all_backends_fail_card_still_returns(self) -> None:
# тотальный сбой: каждый индекс unavailable, но карточка возвращается (не crash).
db = MagicMock()
with (
patch.multiple(
_MOD,
compute_demand_supply_forecast=MagicMock(side_effect=RuntimeError("x")),
rank_segments=MagicMock(side_effect=RuntimeError("x")),
get_competitors=MagicMock(side_effect=RuntimeError("x")),
compute_market_metrics=MagicMock(side_effect=RuntimeError("x")),
compute_affordability=MagicMock(side_effect=RuntimeError("x")),
),
patch(
f"{_MOD}._query_artificial_demand",
side_effect=RuntimeError("x"),
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
assert len(card.indices) == 6
assert all(idx.method == _METHOD_UNAVAILABLE for idx in card.indices.values())
assert all(idx.value is None for idx in card.indices.values())
assert card.advisory is True
assert card.confidence == "low"
def test_launch_window_all_none_degrades(self) -> None:
# deficit None на всех горизонтах → Launch Window unavailable.
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
db = MagicMock()
with (
_full_stack_patch(),
patch(_DSF, return_value=forecasts),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 100, "n_mortgage": 40},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE
assert card.indices[KEY_LAUNCH_WINDOW].value is None
def test_cost_of_error_falls_back_to_negative_deficit(self) -> None:
# overstock_index None → Cost-of-Error берёт магнитуду отрицательного дефицита.
metrics = MagicMock(overstock_index=None, confidence="medium")
afford = MagicMock(price_per_m2=200_000.0)
# forecast на _VOID_HORIZON_MONTHS=12 (fallback) с отрицательным дефицитом.
forecasts = [_forecast_stub(-0.5, horizon=12)]
comp = MagicMock()
comp.competitors = [_competitor_stub(0.7, "комфорт")]
db = MagicMock()
with (
patch.multiple(
_MOD,
compute_demand_supply_forecast=MagicMock(return_value=forecasts),
rank_segments=MagicMock(return_value=MagicMock(ranked=[], n_cells_ranked=0)),
get_competitors=MagicMock(return_value=comp),
compute_market_metrics=MagicMock(return_value=metrics),
compute_affordability=MagicMock(return_value=afford),
),
patch(
f"{_MOD}._query_artificial_demand",
return_value={"n_sold": 50, "n_mortgage": 20},
),
):
card = compute_special_indices(
db, spec=_SPEC, district="Академический", cad_num="66:41:0303161:123"
)
coe = card.indices[KEY_COST_OF_ERROR]
assert coe.detail["risk_source"] == "negative_deficit"
# risk |0.5|=0.5 × ticket(10млн/15млн=0.6667) ≈ 0.3333.
assert coe.value == pytest.approx(0.5 * ((200_000.0 * 50.0) / 15_000_000.0))
def test_aggregate_spec_no_class_param_is_none(self) -> None:
# spec без obj_class → Artificial-Demand SQL получает obj_class=None (агрегат).
db = MagicMock()
captured: dict[str, Any] = {}
def _capture(_db: Any, *, district: Any, obj_class: Any, premise_kind: Any) -> dict:
captured["obj_class"] = obj_class
captured["district"] = district
return {"n_sold": 40, "n_mortgage": 10}
spec_no_class = SegmentSpec(room_bucket="2-к 45-60")
with _full_stack_patch(), patch(f"{_MOD}._query_artificial_demand", side_effect=_capture):
card = compute_special_indices(db, spec=spec_no_class, district=None)
assert captured["obj_class"] is None
assert captured["district"] is None
assert card.indices[KEY_ARTIFICIAL_DEMAND].value == pytest.approx(0.25)