From 60c82b2053789386541565d4f1792238989d6e7c Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 9 Jun 2026 11:50:06 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecasting):=20activate=20=C2=A725.3=20ca?= =?UTF-8?q?nnibalization=20timing=20axis=20from=20launch=20window=20(#1169?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/forecasting/special_indices.py | 116 ++++++++--- .../forecasting/test_special_indices.py | 191 ++++++++++++++++++ 2 files changed, 282 insertions(+), 25 deletions(-) diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 244090b2..e8216423 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -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()]) diff --git a/backend/tests/services/forecasting/test_special_indices.py b/backend/tests/services/forecasting/test_special_indices.py index b555d41d..aa36953f 100644 --- a/backend/tests/services/forecasting/test_special_indices.py +++ b/backend/tests/services/forecasting/test_special_indices.py @@ -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. -- 2.45.3