feat(forecasting): activate §25.3 cannibalization timing axis from launch window (#1169)
Derive candidate_release_month = report-as-of (date.today()) + §25.1 Launch Window peak-deficit horizon, threaded into _build_cannibalization so the timing overlap axis activates against own-portfolio release_month (near-in-time own projects raise cannibalization risk). Launch Window now computed once in compute_special_indices and reused (no double-compute). Launch Window unavailable -> candidate_release_month None -> timing axis gracefully excluded (None-not-0); cannibalization still scores on class/price/geo. Adds stdlib _add_months helper (year-boundary safe, no new dep). Deterministic. 168 tests. §25.3 now: class+price+timing+geo active; unit-mix remains phase-2. Refs #1169
This commit is contained in:
parent
f9e045028c
commit
cd48a095c0
2 changed files with 282 additions and 25 deletions
|
|
@ -519,6 +519,20 @@ def _months_between(a: date, b: date) -> int:
|
||||||
return (a.year - b.year) * 12 + (a.month - b.month)
|
return (a.year - b.year) * 12 + (a.month - b.month)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_months(base: date, months: int) -> date:
|
||||||
|
"""Прибавить N месяцев к дате → ПЕРВОЕ число итогового месяца. PURE.
|
||||||
|
|
||||||
|
Без сторонних зависимостей (НЕ dateutil): год-границу считаем вручную через divmod
|
||||||
|
(зеркалит macro_series._shift_months). months может быть отрицательным/нулём. Результат
|
||||||
|
нормализован к 1-му числу — совпадает с тем, как own-портфель хранит release_month
|
||||||
|
(own_portfolio._month_start) и как тайминговая ось сравнивает запуски. Напр. октябрь +
|
||||||
|
6 → апрель следующего года. Детерминированно.
|
||||||
|
"""
|
||||||
|
total = base.year * 12 + (base.month - 1) + months
|
||||||
|
year, month0 = divmod(total, 12)
|
||||||
|
return date(year, month0 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
def _geo_weight(
|
def _geo_weight(
|
||||||
distance_km: float | None,
|
distance_km: float | None,
|
||||||
*,
|
*,
|
||||||
|
|
@ -900,6 +914,34 @@ def _build_launch_window(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
|
||||||
|
"""Извлечь рекомендованный горизонт запуска (мес) из готового Launch Window. PURE.
|
||||||
|
|
||||||
|
§25.1 кладёт выбранный горизонт пикового дефицита в detail['best_horizon_months'].
|
||||||
|
Индекс недоступен (deficit None на всех горизонтах → method='unavailable', detail без
|
||||||
|
горизонта) → None: тайминговая ось §25.3 НЕ активируется (graceful, НЕ фабрикуем дату).
|
||||||
|
Читаем из УЖЕ посчитанного индекса — Launch Window НЕ пересчитывается. PURE.
|
||||||
|
"""
|
||||||
|
horizon = launch_window.detail.get("best_horizon_months")
|
||||||
|
return horizon if isinstance(horizon, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_release_month(
|
||||||
|
launch_window: SpecialIndex, *, as_of: date
|
||||||
|
) -> date | None:
|
||||||
|
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
|
||||||
|
|
||||||
|
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
|
||||||
|
ПИКОВЫЙ дефицит, т.е. рекомендованный к запуску момент. Этот месяц затем сравнивается с
|
||||||
|
release_month наших проектов (тайминговая ось каннибализации). Launch Window недоступен
|
||||||
|
→ None: ось тайминга остаётся исключённой (None-not-0), а не фабрикуется. PURE.
|
||||||
|
"""
|
||||||
|
horizon = _launch_window_horizon(launch_window)
|
||||||
|
if horizon is None:
|
||||||
|
return None
|
||||||
|
return _add_months(as_of, horizon)
|
||||||
|
|
||||||
|
|
||||||
def _build_product_void(
|
def _build_product_void(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1202,9 +1244,12 @@ def _build_cannibalization(
|
||||||
FALLBACK на ПРОКСИ (_build_cannibalization_proxy), явно помеченный method='proxy_*'
|
FALLBACK на ПРОКСИ (_build_cannibalization_proxy), явно помеченный method='proxy_*'
|
||||||
— прокси НИКОГДА не выдаётся за истинный индекс. Детерминированно.
|
— прокси НИКОГДА не выдаётся за истинный индекс. Детерминированно.
|
||||||
|
|
||||||
candidate_unit_mix / candidate_release_month — опциональные оси квартирографии/
|
candidate_release_month — месяц выхода кандидата на рынок; оркестратор выводит его
|
||||||
тайминга кандидата (из рекомендации what_to_build, если доступны caller'у). Не
|
из §25.1 Launch Window (дата отчёта + горизонт пикового дефицита) и передаёт сюда,
|
||||||
переданы → эти оси НЕДОСТУПНЫ и честно ИСКЛЮЧАЮТСЯ из среднего (None-not-0).
|
активируя тайминговую ось. Launch Window недоступен → None → ось тайминга честно
|
||||||
|
ИСКЛЮЧАЕТСЯ (None-not-0, НЕ фабрикуется). candidate_unit_mix — опциональная ось
|
||||||
|
квартирографии кандидата (phase-2: нужен project-level рекомендованный микс; пока
|
||||||
|
обычно None). Любая не переданная ось ИСКЛЮЧАЕТСЯ из среднего, а НЕ зануляется.
|
||||||
"""
|
"""
|
||||||
portfolio = get_own_portfolio(db)
|
portfolio = get_own_portfolio(db)
|
||||||
if not portfolio:
|
if not portfolio:
|
||||||
|
|
@ -1343,6 +1388,12 @@ def compute_special_indices(
|
||||||
а остальные пять считаются. Карточка возвращается ВСЕГДА (graceful, никогда crash).
|
а остальные пять считаются. Карточка возвращается ВСЕГДА (graceful, никогда crash).
|
||||||
`advisory` ВСЕГДА True; `confidence` = MIN по доступным индексам, ≤ _CONFIDENCE_CAP.
|
`advisory` ВСЕГДА True; `confidence` = MIN по доступным индексам, ≤ _CONFIDENCE_CAP.
|
||||||
|
|
||||||
|
§25.1 Launch Window считается ПЕРВЫМ и переиспользуется: его горизонт пикового
|
||||||
|
дефицита + дата отчёта (date.today() — даты отчёта в scope нет) дают месяц выхода
|
||||||
|
кандидата на рынок, который передаётся в §25.3 каннибализацию и активирует её
|
||||||
|
тайминговую ось. Launch Window недоступен → месяц None → ось тайминга честно
|
||||||
|
исключается (None-not-0), каннибализация считается по классу/цене/гео.
|
||||||
|
|
||||||
Quick-win SOLID (данные есть): Product Void (#981), Artificial Demand (ипотечный
|
Quick-win SOLID (данные есть): Product Void (#981), Artificial Demand (ипотечный
|
||||||
признак objective_lots). Остальные деградируют изящно при тонких данных: Launch
|
признак objective_lots). Остальные деградируют изящно при тонких данных: Launch
|
||||||
Window — если deficit None на всех горизонтах; Cannibalization / Competitor
|
Window — если deficit None на всех горизонтах; Cannibalization / Competitor
|
||||||
|
|
@ -1362,28 +1413,10 @@ def compute_special_indices(
|
||||||
"""
|
"""
|
||||||
segment = spec.as_dict()
|
segment = spec.as_dict()
|
||||||
|
|
||||||
# (ключ, нулевой-аргумент builder) — каждый вызывается в общем try/except ниже.
|
def _run(key: str, builder: Any) -> SpecialIndex:
|
||||||
builders: dict[str, Any] = {
|
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
|
||||||
KEY_LAUNCH_WINDOW: lambda: _build_launch_window(
|
|
||||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizons
|
|
||||||
),
|
|
||||||
KEY_PRODUCT_VOID: lambda: _build_product_void(
|
|
||||||
db, district=district, cad_num=cad_num, horizon_months=_VOID_HORIZON_MONTHS
|
|
||||||
),
|
|
||||||
KEY_CANNIBALIZATION: lambda: _build_cannibalization(db, spec=spec, cad_num=cad_num),
|
|
||||||
KEY_COMPETITOR_STRENGTH: lambda: _build_competitor_strength(db, cad_num=cad_num),
|
|
||||||
KEY_ARTIFICIAL_DEMAND: lambda: _build_artificial_demand(
|
|
||||||
db, spec=spec, district=district, premise_kind=premise_kind
|
|
||||||
),
|
|
||||||
KEY_COST_OF_ERROR: lambda: _build_cost_of_error(
|
|
||||||
db, spec=spec, district=district, cad_num=cad_num, premise_kind=premise_kind
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
indices: dict[str, SpecialIndex] = {}
|
|
||||||
for key in _INDEX_KEYS:
|
|
||||||
try:
|
try:
|
||||||
indices[key] = builders[key]()
|
return builder() # type: ignore[no-any-return]
|
||||||
except Exception:
|
except Exception:
|
||||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
@ -1393,7 +1426,40 @@ def compute_special_indices(
|
||||||
district,
|
district,
|
||||||
cad_num,
|
cad_num,
|
||||||
)
|
)
|
||||||
indices[key] = _unavailable(key, reason="ошибка расчёта (см. логи)")
|
return _unavailable(key, reason="ошибка расчёта (см. логи)")
|
||||||
|
|
||||||
|
# §25.1 Launch Window считаем ПЕРВЫМ и ОДИН раз: §25.3 каннибализация выводит из его
|
||||||
|
# горизонта тайминг кандидата (дата отчёта + горизонт пикового дефицита = когда проект
|
||||||
|
# реально выйдет на рынок). Готовый индекс переиспользуется ниже (НЕ пересчитывается).
|
||||||
|
launch_window = _run(
|
||||||
|
KEY_LAUNCH_WINDOW,
|
||||||
|
lambda: _build_launch_window(
|
||||||
|
db, spec=spec, district=district, cad_num=cad_num, horizons=horizons
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# report-as-of: дата отчёта в scope недоступна → date.today() (app-код — допустимо).
|
||||||
|
# Launch Window недоступен → release_month None → тайминговая ось §25.3 исключается.
|
||||||
|
candidate_release_month = _candidate_release_month(launch_window, as_of=date.today())
|
||||||
|
|
||||||
|
# (ключ, нулевой-аргумент builder) — каждый вызывается в _run (общий try/except).
|
||||||
|
builders: dict[str, Any] = {
|
||||||
|
KEY_LAUNCH_WINDOW: lambda: launch_window, # уже посчитан выше
|
||||||
|
KEY_PRODUCT_VOID: lambda: _build_product_void(
|
||||||
|
db, district=district, cad_num=cad_num, horizon_months=_VOID_HORIZON_MONTHS
|
||||||
|
),
|
||||||
|
KEY_CANNIBALIZATION: lambda: _build_cannibalization(
|
||||||
|
db, spec=spec, cad_num=cad_num, candidate_release_month=candidate_release_month
|
||||||
|
),
|
||||||
|
KEY_COMPETITOR_STRENGTH: lambda: _build_competitor_strength(db, cad_num=cad_num),
|
||||||
|
KEY_ARTIFICIAL_DEMAND: lambda: _build_artificial_demand(
|
||||||
|
db, spec=spec, district=district, premise_kind=premise_kind
|
||||||
|
),
|
||||||
|
KEY_COST_OF_ERROR: lambda: _build_cost_of_error(
|
||||||
|
db, spec=spec, district=district, cad_num=cad_num, premise_kind=premise_kind
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
|
||||||
|
|
||||||
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
confidence = _min_confidence([idx.confidence for idx in indices.values()])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,12 @@ from app.services.forecasting.special_indices import (
|
||||||
KEY_COST_OF_ERROR,
|
KEY_COST_OF_ERROR,
|
||||||
KEY_LAUNCH_WINDOW,
|
KEY_LAUNCH_WINDOW,
|
||||||
KEY_PRODUCT_VOID,
|
KEY_PRODUCT_VOID,
|
||||||
|
SpecialIndex,
|
||||||
|
_add_months,
|
||||||
_aggregate_overlap,
|
_aggregate_overlap,
|
||||||
_artificial_demand_share,
|
_artificial_demand_share,
|
||||||
_avg_ticket_rub,
|
_avg_ticket_rub,
|
||||||
|
_candidate_release_month,
|
||||||
_cannibalization_index,
|
_cannibalization_index,
|
||||||
_cap_confidence,
|
_cap_confidence,
|
||||||
_clamp01,
|
_clamp01,
|
||||||
|
|
@ -50,6 +53,7 @@ from app.services.forecasting.special_indices import (
|
||||||
_count_void,
|
_count_void,
|
||||||
_geo_weight,
|
_geo_weight,
|
||||||
_haversine_km,
|
_haversine_km,
|
||||||
|
_launch_window_horizon,
|
||||||
_min_confidence,
|
_min_confidence,
|
||||||
_oversupply_risk_from_deficit,
|
_oversupply_risk_from_deficit,
|
||||||
_own_portfolio_overlap,
|
_own_portfolio_overlap,
|
||||||
|
|
@ -423,6 +427,75 @@ class TestTimingOverlap:
|
||||||
assert v is not None and 0.0 <= v <= 1.0
|
assert v is not None and 0.0 <= v <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMonths:
|
||||||
|
def test_within_year(self) -> None:
|
||||||
|
assert _add_months(date(2026, 1, 1), 5) == date(2026, 6, 1)
|
||||||
|
|
||||||
|
def test_year_boundary_oct_plus_six_is_apr_next_year(self) -> None:
|
||||||
|
# Oct + 6 = Apr следующего года (явный year-boundary кейс из ТЗ).
|
||||||
|
assert _add_months(date(2026, 10, 1), 6) == date(2027, 4, 1)
|
||||||
|
|
||||||
|
def test_exactly_twelve_months_same_month_next_year(self) -> None:
|
||||||
|
assert _add_months(date(2026, 3, 1), 12) == date(2027, 3, 1)
|
||||||
|
|
||||||
|
def test_twenty_four_months_two_years(self) -> None:
|
||||||
|
assert _add_months(date(2026, 6, 1), 24) == date(2028, 6, 1)
|
||||||
|
|
||||||
|
def test_zero_months_normalizes_to_first_of_month(self) -> None:
|
||||||
|
# +0 мес всё равно нормализует к 1-му числу (день отбрасывается).
|
||||||
|
assert _add_months(date(2026, 6, 17), 0) == date(2026, 6, 1)
|
||||||
|
|
||||||
|
def test_december_rollover(self) -> None:
|
||||||
|
assert _add_months(date(2026, 12, 1), 1) == date(2027, 1, 1)
|
||||||
|
|
||||||
|
def test_negative_months_go_back_across_year(self) -> None:
|
||||||
|
assert _add_months(date(2026, 2, 1), -3) == date(2025, 11, 1)
|
||||||
|
|
||||||
|
def test_result_is_always_first_of_month(self) -> None:
|
||||||
|
for m in range(1, 25):
|
||||||
|
assert _add_months(date(2026, 7, 23), m).day == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_window_index(horizon: int | None) -> SpecialIndex:
|
||||||
|
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
|
||||||
|
if horizon is None:
|
||||||
|
return SpecialIndex(
|
||||||
|
key=KEY_LAUNCH_WINDOW, value=None, label=None, confidence="low",
|
||||||
|
detail={"reason": "deficit None на всех горизонтах"},
|
||||||
|
method=_METHOD_UNAVAILABLE, advisory=True,
|
||||||
|
)
|
||||||
|
return SpecialIndex(
|
||||||
|
key=KEY_LAUNCH_WINDOW, value=0.8, label=f"{horizon} мес", confidence="medium",
|
||||||
|
detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
|
||||||
|
method="deficit_peak_scan", advisory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaunchWindowHorizon:
|
||||||
|
def test_reads_best_horizon(self) -> None:
|
||||||
|
assert _launch_window_horizon(_launch_window_index(18)) == 18
|
||||||
|
|
||||||
|
def test_unavailable_index_is_none(self) -> None:
|
||||||
|
# Launch Window недоступен (нет best_horizon_months) → None (ось тайминга off).
|
||||||
|
assert _launch_window_horizon(_launch_window_index(None)) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCandidateReleaseMonth:
|
||||||
|
def test_derives_as_of_plus_horizon(self) -> None:
|
||||||
|
# дата отчёта + горизонт окна запуска = месяц выхода кандидата на рынок.
|
||||||
|
m = _candidate_release_month(_launch_window_index(12), as_of=date(2026, 6, 9))
|
||||||
|
assert m == date(2027, 6, 1)
|
||||||
|
|
||||||
|
def test_horizon_crosses_year_boundary(self) -> None:
|
||||||
|
# окт + 6 мес → апр следующего года (year-boundary в деривации тайминга).
|
||||||
|
m = _candidate_release_month(_launch_window_index(6), as_of=date(2026, 10, 20))
|
||||||
|
assert m == date(2027, 4, 1)
|
||||||
|
|
||||||
|
def test_unavailable_launch_window_is_none(self) -> None:
|
||||||
|
# Launch Window недоступен → release_month None → тайминговая ось исключается.
|
||||||
|
assert _candidate_release_month(_launch_window_index(None), as_of=date(2026, 6, 9)) is None
|
||||||
|
|
||||||
|
|
||||||
class TestGeoWeight:
|
class TestGeoWeight:
|
||||||
def test_zero_distance_full_weight(self) -> None:
|
def test_zero_distance_full_weight(self) -> None:
|
||||||
assert _geo_weight(0.0) == pytest.approx(1.0)
|
assert _geo_weight(0.0) == pytest.approx(1.0)
|
||||||
|
|
@ -1241,6 +1314,124 @@ def _cannibalization_card(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Дата отчёта фиксируется в тестах тайминга, чтобы выведенный месяц кандидата был
|
||||||
|
# детерминирован вне зависимости от реального date.today(). _full_stack_patch даёт пик
|
||||||
|
# дефицита на горизонте 24 мес → candidate_release_month = _FIXED_TODAY + 24 мес.
|
||||||
|
_FIXED_TODAY = date(2026, 6, 9)
|
||||||
|
_DERIVED_CANDIDATE_MONTH = date(2028, 6, 1) # 2026-06 + 24 мес, 1-е число
|
||||||
|
|
||||||
|
|
||||||
|
class _FixedDate(date):
|
||||||
|
"""date с фиксированным today() (construction делегируется реальному date). PURE."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def today(cls) -> date: # type: ignore[override]
|
||||||
|
return _FIXED_TODAY
|
||||||
|
|
||||||
|
|
||||||
|
def _timing_card(
|
||||||
|
portfolio: list[OwnProject],
|
||||||
|
*,
|
||||||
|
centroid: tuple[float, float] | None = _CENTROID,
|
||||||
|
cad_num: str | None = "66:41:0303161:123",
|
||||||
|
) -> Any:
|
||||||
|
"""Как _cannibalization_card, но с зафиксированной датой отчёта (тайминговая ось)."""
|
||||||
|
db = MagicMock()
|
||||||
|
with (
|
||||||
|
_full_stack_patch(),
|
||||||
|
patch(f"{_MOD}.date", _FixedDate),
|
||||||
|
patch(f"{_MOD}.get_own_portfolio", return_value=portfolio),
|
||||||
|
patch(f"{_MOD}._query_parcel_centroid", return_value=centroid),
|
||||||
|
patch(
|
||||||
|
f"{_MOD}._query_artificial_demand",
|
||||||
|
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return compute_special_indices(
|
||||||
|
db, spec=_CAND_SPEC, district="Академический", cad_num=cad_num
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
||||||
|
"""§25.3 тайминговая ось активируется из §25.1 Launch Window (#1169 follow-up)."""
|
||||||
|
|
||||||
|
def test_timing_axis_contributes_when_launch_window_resolves(self) -> None:
|
||||||
|
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
|
||||||
|
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
|
||||||
|
own = _own(
|
||||||
|
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||||
|
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||||
|
)
|
||||||
|
card = _timing_card([own])
|
||||||
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
|
assert can.detail["axes_available"]["timing"] == 1 # ось активна (НЕ 0)
|
||||||
|
top = can.detail["top_contributors"][0]
|
||||||
|
assert top["axes"]["timing"] == pytest.approx(1.0) # тот же месяц выхода
|
||||||
|
assert top["n_axes"] == 3 # class + price + timing (unit_mix всё ещё None)
|
||||||
|
|
||||||
|
def test_near_in_time_project_scores_higher_than_far(self) -> None:
|
||||||
|
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
|
||||||
|
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
|
||||||
|
near = _timing_card(
|
||||||
|
[_own("Близкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||||
|
release_month=_DERIVED_CANDIDATE_MONTH)]
|
||||||
|
)
|
||||||
|
far = _timing_card(
|
||||||
|
[_own("Далёкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||||
|
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48))]
|
||||||
|
)
|
||||||
|
near_v = near.indices[KEY_CANNIBALIZATION].value
|
||||||
|
far_v = far.indices[KEY_CANNIBALIZATION].value
|
||||||
|
assert near_v is not None and far_v is not None
|
||||||
|
assert near_v > far_v
|
||||||
|
|
||||||
|
def test_timing_excluded_when_launch_window_unavailable(self) -> None:
|
||||||
|
# Launch Window недоступен (deficit None на всех горизонтах) → release_month None
|
||||||
|
# → тайминговая ось ИСКЛЮЧЕНА (None-not-0), но каннибализация считается по
|
||||||
|
# классу/цене/гео (не падает, не фабрикует тайминг).
|
||||||
|
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
|
||||||
|
own = _own(
|
||||||
|
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||||
|
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
with (
|
||||||
|
_full_stack_patch(),
|
||||||
|
patch(_DSF, return_value=forecasts),
|
||||||
|
patch(f"{_MOD}.date", _FixedDate),
|
||||||
|
patch(f"{_MOD}.get_own_portfolio", return_value=[own]),
|
||||||
|
patch(f"{_MOD}._query_parcel_centroid", return_value=_CENTROID),
|
||||||
|
patch(
|
||||||
|
f"{_MOD}._query_artificial_demand",
|
||||||
|
return_value={"n_sold": 100, "n_mortgage": 40},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
card = compute_special_indices(
|
||||||
|
db, spec=_CAND_SPEC, district="Академический", cad_num="66:41:0303161:123"
|
||||||
|
)
|
||||||
|
assert card.indices[KEY_LAUNCH_WINDOW].method == _METHOD_UNAVAILABLE
|
||||||
|
can = card.indices[KEY_CANNIBALIZATION]
|
||||||
|
# каннибализация всё равно посчитана (класс+цена), тайминговая ось исключена.
|
||||||
|
assert can.method == "own_portfolio_overlap"
|
||||||
|
assert can.value is not None
|
||||||
|
assert can.detail["axes_available"]["timing"] == 0 # НЕ сфабрикована
|
||||||
|
assert can.detail["top_contributors"][0]["axes"]["timing"] is None
|
||||||
|
|
||||||
|
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
|
||||||
|
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
|
||||||
|
portfolio = [
|
||||||
|
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||||
|
release_month=_DERIVED_CANDIDATE_MONTH),
|
||||||
|
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
||||||
|
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6)),
|
||||||
|
]
|
||||||
|
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||||
|
second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||||
|
assert first == second
|
||||||
|
# подтверждаем, что тайминг реально участвовал (ось активна) — не пустой детерминизм.
|
||||||
|
assert first["detail"]["axes_available"]["timing"] == 2
|
||||||
|
|
||||||
|
|
||||||
class TestCannibalizationTrueMode:
|
class TestCannibalizationTrueMode:
|
||||||
def test_nonempty_portfolio_uses_own_portfolio_mode(self) -> None:
|
def test_nonempty_portfolio_uses_own_portfolio_mode(self) -> None:
|
||||||
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
|
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue