feat(forecasting): activate §25.3 cannibalization timing axis from launch window (#1169)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m31s

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:
Light1YT 2026-06-09 11:50:06 +05:00
parent 4adfef2ad0
commit 60c82b2053
2 changed files with 282 additions and 25 deletions

View file

@ -519,6 +519,20 @@ def _months_between(a: date, b: date) -> int:
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(
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(
db: Session,
*,
@ -1202,9 +1244,12 @@ def _build_cannibalization(
FALLBACK на ПРОКСИ (_build_cannibalization_proxy), явно помеченный method='proxy_*'
прокси НИКОГДА не выдаётся за истинный индекс. Детерминированно.
candidate_unit_mix / candidate_release_month опциональные оси квартирографии/
тайминга кандидата (из рекомендации what_to_build, если доступны caller'у). Не
переданы эти оси НЕДОСТУПНЫ и честно ИСКЛЮЧАЮТСЯ из среднего (None-not-0).
candidate_release_month месяц выхода кандидата на рынок; оркестратор выводит его
из §25.1 Launch Window (дата отчёта + горизонт пикового дефицита) и передаёт сюда,
активируя тайминговую ось. Launch Window недоступен None ось тайминга честно
ИСКЛЮЧАЕТСЯ (None-not-0, НЕ фабрикуется). candidate_unit_mix опциональная ось
квартирографии кандидата (phase-2: нужен project-level рекомендованный микс; пока
обычно None). Любая не переданная ось ИСКЛЮЧАЕТСЯ из среднего, а НЕ зануляется.
"""
portfolio = get_own_portfolio(db)
if not portfolio:
@ -1343,6 +1388,12 @@ def compute_special_indices(
а остальные пять считаются. Карточка возвращается ВСЕГДА (graceful, никогда crash).
`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 (ипотечный
признак objective_lots). Остальные деградируют изящно при тонких данных: Launch
Window если deficit None на всех горизонтах; Cannibalization / Competitor
@ -1362,28 +1413,10 @@ def compute_special_indices(
"""
segment = spec.as_dict()
# (ключ, нулевой-аргумент builder) — каждый вызывается в общем try/except ниже.
builders: dict[str, Any] = {
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:
def _run(key: str, builder: Any) -> SpecialIndex:
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
try:
indices[key] = builders[key]()
return builder() # type: ignore[no-any-return]
except Exception:
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
logger.exception(
@ -1393,7 +1426,40 @@ def compute_special_indices(
district,
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()])

View file

@ -38,9 +38,12 @@ from app.services.forecasting.special_indices import (
KEY_COST_OF_ERROR,
KEY_LAUNCH_WINDOW,
KEY_PRODUCT_VOID,
SpecialIndex,
_add_months,
_aggregate_overlap,
_artificial_demand_share,
_avg_ticket_rub,
_candidate_release_month,
_cannibalization_index,
_cap_confidence,
_clamp01,
@ -50,6 +53,7 @@ from app.services.forecasting.special_indices import (
_count_void,
_geo_weight,
_haversine_km,
_launch_window_horizon,
_min_confidence,
_oversupply_risk_from_deficit,
_own_portfolio_overlap,
@ -423,6 +427,75 @@ class TestTimingOverlap:
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:
def test_zero_distance_full_weight(self) -> None:
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:
def test_nonempty_portfolio_uses_own_portfolio_mode(self) -> None:
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.