feat(forecasting): activate §25.3 cannibalization timing axis (#1169) #1176
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)
|
||||
|
||||
|
||||
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()])
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue