feat(financial/dcf): dormant separate-index inflation capability (default OFF) #1906

Merged
bot-backend merged 1 commit from feat/dcf-inflation-indices into main 2026-06-25 12:12:02 +00:00
2 changed files with 281 additions and 14 deletions

View file

@ -303,6 +303,27 @@ _SALES_DURATION_MAX: int = 120
# Сохранён константой для будущей рассрочки (PR-4).
_FIRST_PAYMENT_PCT: float = 0.80
# ── DCF: РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ──────────
# Excel-эталон применяет РАЗНУЮ годовую инфляцию к затратам СМР (индекс СМР Росстата
# ~7-8%/год) и к цене продажи (индекс цен ~6%/год) на многолетнем горизонте. Эта
# возможность ПРОВОДНА в `_build_monthly_cashflow` (см. `smr_inflation_annual` /
# `sale_inflation_annual`), но ДЕФОЛТ строго 0.0 — прод-поведение НЕ меняется.
#
# ПОЧЕМУ OFF ПО УМОЛЧАНИЮ (hard constraint):
# * Статический БДР (`compute_financial`) считается в деньгах СЕГОДНЯ (today-money);
# есть ТЕСТИРУЕМЫЙ инвариант `Σ undiscounted cashflow == net_profit`.
# * Раздельная инфляция делает НОМИНАЛЬНЫЕ помесячные потоки РАСХОДЯЩИМИСЯ с
# today-money `net_profit` → инвариант ломается при ставке > 0.
# * Поэтому включение требует (а) КАЛИБРОВАННЫХ индексов (СМР ~7-8%/год, цена
# ~6%/год — ориентиры Росстата) И (б) переосмысления headline-инварианта
# (номинальный DCF vs today-money БДР — это РАЗНЫЕ метрики). До этого решения
# ставки держим 0.0: capability разведена и протестирована, но выключена.
#
# При 0.0 месячный фактор `(1+0)**t == 1.0` для любого t → побайтово идентичный
# cashflow → существующий инвариант и все текущие тесты проходят без изменений.
_SMR_INFLATION_ANNUAL: float = 0.0
_SALE_INFLATION_ANNUAL: float = 0.0
# ── DCF helpers — hand-rolled, чистая математика (без numpy-financial / deps) ───
@ -495,6 +516,8 @@ def _build_monthly_cashflow(
construction_duration_months: int,
residential_area_sqm: float,
market_velocity_sqm_per_month: float | None = None,
smr_inflation_annual: float = 0.0,
sale_inflation_annual: float = 0.0,
) -> _CashflowResult:
"""Разложить статический каскад в помесячный чистый cashflow по графику фаз.
@ -503,6 +526,26 @@ def _build_monthly_cashflow(
по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
прорейтятся на признание выручки. Выручка полностью в месяц продажи (упрощение).
РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ DORMANT CAPABILITY (default OFF, см. модульные
константы `_SMR_INFLATION_ANNUAL` / `_SALE_INFLATION_ANNUAL`):
* ``smr_inflation_annual`` годовая инфляция СМР-стороны затрат (индекс СМР):
эскалирует ПИР, СМР+сети+услуги+резерв. ЗЕМЛЯ НЕ эскалируется это лумп-сумма
в мес 0 (платится сегодня, t=0 фактор `(1+r)**0 == 1.0` и так бы дал нейтрал,
но земля исключена ЯВНО: участок покупается по сегодняшней цене, будущий индекс
СМР к ней неприменим).
* ``sale_inflation_annual`` годовая инфляция цены продажи (индекс цен): эскалирует
приток выручки. Маркетинг/НДС/налог прорейтятся на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку
(доля = эскалированная revenue_cf[m] / Σ эскалированной выручки) они едут на
той же номинальной выручке, что их рождает (консистентно).
* Месячный фактор: ``smr_m = (1+smr_inflation_annual)**(1/12) 1``, аналогично
``sale_m``; компонент месяца ``t`` (0-based) множится на ``(1+factor_m)**t``.
* ДЕФОЛТ 0.0 ``(1+0)**t == 1.0`` для всех t ПОБАЙТОВО идентичный cashflow
инвариант ниже и все текущие тесты держатся (safety-гарантия zero-diff).
ВНИМАНИЕ: при ставке > 0 номинальные потоки РАСХОДЯТСЯ с today-money ``net_profit``
инвариант ниже верен ТОЛЬКО при нулевой инфляции (today-money режим). Это
ожидаемо: номинальный DCF != today-money БДР (см. модульные константы).
RANK 1 окно продаж по рыночной абсорбции: если передан конечный
``market_velocity_sqm_per_month > 0`` (темп выбытия ОДНОГО типового конкурента
района) и ``residential_area_sqm > 0``, базовое окно =
@ -546,33 +589,55 @@ def _build_monthly_cashflow(
# Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1).
horizon = max(constr_end, sales_end, 1)
# ── Месячные факторы инфляции (DORMANT: при ставке 0.0 фактор тождественно 1.0) ─
# smr_m / sale_m — месячные эквиваленты годовых ставок. (1+0)**(1/12)1 == 0.0 →
# все факторы (1+0)**t == 1.0 → побайтово идентичный cashflow при дефолте (zero-diff).
smr_m = (1.0 + smr_inflation_annual) ** (1.0 / 12.0) - 1.0
sale_m = (1.0 + sale_inflation_annual) ** (1.0 / 12.0) - 1.0
smr_factor = [(1.0 + smr_m) ** t for t in range(horizon)]
sale_factor = [(1.0 + sale_m) ** t for t in range(horizon)]
# ── Затраты (отток, со знаком ) ───────────────────────────────────────────
# ЗЕМЛЯ НЕ эскалируется: лумп-сумма в мес 0 (участок покупается по сегодняшней цене,
# индекс СМР к ней неприменим). При t=0 фактор и так == 1.0, но исключаем явно.
land_cf = _spread_linear(land, 0, 0, horizon) # земля — единоразово в мес 0
pir_cf = _spread_linear(pir, 0, pir_end, horizon) # ПИР линейно [0..pir_end)
# ПИР и СМР+сети+услуги+резерв — СМР-сторона: эскалируются индексом СМР по месяцу t.
pir_cf = [c * smr_factor[m] for m, c in enumerate(_spread_linear(pir, 0, pir_end, horizon))]
# СМР+сети+услуги+резерв линейно по окну строительства [pir_end .. constr_end).
constr_total = construction + networks + dev_services + contingency
constr_cf = _spread_linear(constr_total, pir_end, construction_duration_months, horizon)
constr_cf = [
c * smr_factor[m]
for m, c in enumerate(
_spread_linear(constr_total, pir_end, construction_duration_months, horizon)
)
]
# ── Выручка (приток, +) и привязанные к ней маркетинг/налоги ────────────────
# Темп продаж пересчитан на ФАКТИЧЕСКОЕ окно [sales_start, sales_end): выручка
# линейно по sales_duration (>= _SALES_DURATION_MONTHS, растянуто до ввода).
revenue_cf = _spread_linear(revenue, sales_start, sales_duration, horizon)
# Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки: доля месяца
# = revenue_cf[m] / revenue. Так отток налога идёт за притоком, который его рождает,
# и Σ undiscounted cashflow == net_profit (инвариант). При revenue==0 — нули.
# Выручка эскалируется индексом цены продажи по месяцу t (sale_factor).
revenue_cf = [
c * sale_factor[m]
for m, c in enumerate(_spread_linear(revenue, sales_start, sales_duration, horizon))
]
# Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки. Прорейт идёт
# на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку: доля месяца = revenue_cf[m] / total_nominal_revenue
# → они едут на той же номинальной выручке, что их рождает (консистентно).
# ZERO-DIFF GUARANTEE: при выключенной инфляции продаж знаменатель = сырая ``revenue``
# (ровно как прежде, без зависимости от порядка float-суммирования) → формула
# ПОБАЙТОВО тождественна прежней → Σ undiscounted cashflow == net_profit (инвариант).
# При revenue==0 — нули.
tax_marketing_total = marketing + vat + profit_tax
if revenue > 0.0:
prorate = [tax_marketing_total * (revenue_cf[m] / revenue) for m in range(horizon)]
total_nominal_revenue = revenue if sale_inflation_annual == 0.0 else sum(revenue_cf)
if total_nominal_revenue > 0.0:
prorate = [
tax_marketing_total * (revenue_cf[m] / total_nominal_revenue) for m in range(horizon)
]
else:
prorate = [0.0] * horizon
cashflow = [
revenue_cf[m]
- land_cf[m]
- pir_cf[m]
- constr_cf[m]
- prorate[m]
for m in range(horizon)
revenue_cf[m] - land_cf[m] - pir_cf[m] - constr_cf[m] - prorate[m] for m in range(horizon)
]
return _CashflowResult(
cashflow=cashflow,
@ -708,6 +773,11 @@ def compute_financial(
construction_duration_months=construction_months,
residential_area_sqm=teap.residential_area_sqm,
market_velocity_sqm_per_month=market_velocity_sqm_per_month,
# DORMANT capability: раздельная инфляция СМР/цены. Дефолт 0.0 → zero-diff
# (фактор (1+0)**t == 1.0). Включение требует калибровки индексов И
# переосмысления инварианта Σ cashflow == net_profit (см. модульные константы).
smr_inflation_annual=_SMR_INFLATION_ANNUAL,
sale_inflation_annual=_SALE_INFLATION_ANNUAL,
)
cashflow = cf_result.cashflow
# Годовая ставка → месячная: (1+r_год)**(1/12) 1.

View file

@ -835,3 +835,200 @@ def test_velocity_invariant_holds_across_window_sizes() -> None:
- 40_000_000.0
)
assert abs(sum(cf) - expected) < 1.0, vel
# ── РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ───────────────
def _build_inflation(
*,
smr_inflation_annual: float = 0.0,
sale_inflation_annual: float = 0.0,
construction_months: int = 36,
revenue: float = 1_000_000_000.0,
construction: float = 400_000_000.0,
pir: float = 10_000_000.0,
networks: float = 30_000_000.0,
dev_services: float = 6_000_000.0,
contingency: float = 13_000_000.0,
marketing: float = 70_000_000.0,
land: float = 100_000_000.0,
vat: float = 50_000_000.0,
profit_tax: float = 40_000_000.0,
) -> object:
"""Прямой вызов _build_monthly_cashflow с явными ставками инфляции (capability)."""
return _build_monthly_cashflow(
revenue=revenue,
construction=construction,
pir=pir,
networks=networks,
dev_services=dev_services,
contingency=contingency,
marketing=marketing,
land=land,
vat=vat,
profit_tax=profit_tax,
construction_duration_months=construction_months,
residential_area_sqm=22_000.0,
market_velocity_sqm_per_month=None,
smr_inflation_annual=smr_inflation_annual,
sale_inflation_annual=sale_inflation_annual,
)
def test_module_inflation_constants_default_off() -> None:
# SAFETY: модульные константы строго 0.0 (capability разведена, но выключена в проде).
assert financial._SMR_INFLATION_ANNUAL == 0.0
assert financial._SALE_INFLATION_ANNUAL == 0.0
def test_zero_diff_explicit_zero_vs_omitted_byte_identical() -> None:
# ZERO-DIFF (критично): дефолт без аргументов vs явные 0.0 → ПОБАЙТОВО идентичный
# cashflow. Пин: прод (константы 0.0) не изменился вообще.
omitted = _build(residential=22_000.0, velocity=None, construction_months=36)
explicit = _build_inflation(
smr_inflation_annual=0.0, sale_inflation_annual=0.0, construction_months=36
)
cf_omitted = omitted.cashflow # type: ignore[attr-defined]
cf_explicit = explicit.cashflow # type: ignore[attr-defined]
assert cf_explicit == cf_omitted # списки float идентичны элемент-в-элемент
def test_zero_diff_npv_irr_invariant_unchanged_at_default() -> None:
# ZERO-DIFF: NPV, IRR и инвариант Σ cashflow == net_profit идентичны при явных 0.0.
omitted = _build(residential=22_000.0, velocity=None, construction_months=48)
explicit = _build_inflation(
sale_inflation_annual=0.0, smr_inflation_annual=0.0, construction_months=48
)
cf_o = omitted.cashflow # type: ignore[attr-defined]
cf_e = explicit.cashflow # type: ignore[attr-defined]
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
assert _npv(monthly_rate, cf_e) == _npv(monthly_rate, cf_o)
assert _irr_monthly(cf_e) == _irr_monthly(cf_o)
# Инвариант (today-money): Σ == revenue все затраты vat tax.
expected = (
1_000_000_000.0
- 400_000_000.0
- 10_000_000.0
- 30_000_000.0
- 6_000_000.0
- 13_000_000.0
- 70_000_000.0
- 100_000_000.0
- 50_000_000.0
- 40_000_000.0
)
assert abs(sum(cf_e) - expected) < 1.0
def test_compute_financial_zero_diff_with_module_defaults() -> None:
# ZERO-DIFF на уровне compute_financial: пересборка cashflow с теми же ставками,
# что использует прод (константы 0.0), даёт инвариант Σ == net_profit (как было).
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
# _rebuild_cashflow не передаёт ставки → дефолт 0.0 → тот же поток, что построил прод.
assert abs(sum(cf) - model.net_profit_rub) < 1.0
def test_inflation_escalates_late_cost_by_monthly_compound_factor() -> None:
# COMPOUNDING (a): поздний СМР-отток = базовый × (1+smr_m)**t, где
# smr_m = (1.08)**(1/12) 1. Сравниваем месяц t для базового vs инфлированного
# потока → отношение == ровно фактор компаундинга месяца t.
base = _build_inflation(smr_inflation_annual=0.0, construction_months=36)
infl = _build_inflation(smr_inflation_annual=0.08, construction_months=36)
cf_base = base.cashflow # type: ignore[attr-defined]
cf_infl = infl.cashflow # type: ignore[attr-defined]
smr_m = (1.08) ** (1.0 / 12.0) - 1.0
# Возьмём месяц ВНУТРИ окна СМР, ДО старта продаж (чистый СМР-отток, без выручки):
# pir_end=6, constr_start=6, sales_start=6+6=12. Месяц 10 — чистый СМР+сети+резерв.
t = 10
# Затраты — отрицательные; отношение по модулю == фактор компаундинга.
factor = (1.0 + smr_m) ** t
assert cf_infl[t] == pytest.approx(cf_base[t] * factor, rel=1e-9)
# Sanity: фактор для t=10 близок к (1.08)**(10/12).
assert factor == pytest.approx(1.08 ** (10.0 / 12.0), rel=1e-9)
def test_inflation_revenue_escalates_by_sale_factor() -> None:
# COMPOUNDING: приток выручки месяца t = базовый × (1+sale_m)**t. Берём проект без
# затрат в окне продаж, чтобы изолировать выручку: месяц с чистым притоком.
base = _build_inflation(sale_inflation_annual=0.0, construction_months=24)
infl = _build_inflation(sale_inflation_annual=0.06, construction_months=24)
cf_base = base.cashflow # type: ignore[attr-defined]
cf_infl = infl.cashflow # type: ignore[attr-defined]
# Последний месяц горизонта — чистый приток (СМР завершён, продажи идут): на нём
# выручка эскалирована сильнее всего. Проверяем направление: инфл-приток > базового.
last = len(cf_base) - 1
assert cf_infl[last] > cf_base[last]
def test_nominal_cashflow_sum_diverges_from_net_profit_when_on() -> None:
# COMPOUNDING (b): при ставках > 0 Σ НОМИНАЛЬНОГО потока != today-money сумме.
# Это ОЖИДАЕМО (номинальный DCF vs today-money БДР — разные метрики), а не баг.
today_money_sum = (
1_000_000_000.0
- 400_000_000.0
- 10_000_000.0
- 30_000_000.0
- 6_000_000.0
- 13_000_000.0
- 70_000_000.0
- 100_000_000.0
- 50_000_000.0
- 40_000_000.0
)
infl = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.06)
cf = infl.cashflow # type: ignore[attr-defined]
# Номинальная сумма ОТЛИЧАЕТСЯ от today-money (инвариант намеренно не держится on).
assert abs(sum(cf) - today_money_sum) > 1.0
def test_npv_lower_when_cost_inflation_applied_leverage_on_cost() -> None:
# COMPOUNDING (c): ИЗОЛИРОВАННЫЙ эффект СМР-инфляции — leverage на затраты.
# СМР-инфляция эскалирует ОТТОКИ (затраты), не трогая притоки → NPV строго НИЖЕ
# нулевого сценария. Это однозначное направление «leverage on cost»: затраты
# дорожают, выручка нет → ценность падает. Проверяем НАПРАВЛЕНИЕ, не магич. число.
#
# ПРИМЕЧАНИЕ (почему изолируем СМР, а не сравниваем smr8/sale6 vs zero): на этом
# фикстуре выручка (1.0B) много больше затрат, поэтому sale-инфляция 6% на крупном
# back-loaded притоке ПЕРЕВЕШИВАЕТ cost-инфляцию 8% — комбинированный NPV выходит ВЫШЕ
# нулевого. Это корректно (направление комбинации зависит от отношения выручка/затраты);
# «leverage on cost» — про ИЗОЛИРОВАННЫЙ cost-канал, который монотонно режет NPV.
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
zero = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.0)
cost_only = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.0)
npv_zero = _npv(monthly_rate, zero.cashflow) # type: ignore[attr-defined]
npv_cost = _npv(monthly_rate, cost_only.cashflow) # type: ignore[attr-defined]
assert npv_cost < npv_zero
# Зеркало: ИЗОЛИРОВАННАЯ sale-инфляция эскалирует притоки → NPV ВЫШЕ нулевого.
sale_only = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.06)
npv_sale = _npv(monthly_rate, sale_only.cashflow) # type: ignore[attr-defined]
assert npv_sale > npv_zero
def test_npv_monotonic_decreasing_in_smr_inflation() -> None:
# MONOTONICITY: выше СМР-инфляция → ниже (или равный) NPV, всё прочее равно
# (sale_inflation фиксирована 0). Проверяем по сетке ставок.
monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
rates = [0.0, 0.02, 0.05, 0.08, 0.12]
npvs = [
_npv(monthly_rate, _build_inflation(smr_inflation_annual=r).cashflow) # type: ignore[attr-defined]
for r in rates
]
for prev, nxt in pairwise(npvs):
assert nxt <= prev # NPV не растёт при росте СМР-инфляции
def test_land_not_escalated_by_smr_inflation() -> None:
# ЗЕМЛЯ НЕ эскалируется: лумп в мес 0. Меняем ТОЛЬКО землю при той же СМР-инфляции —
# cashflow[0] двигается ровно на дельту земли (без множителя инфляции).
a = _build_inflation(smr_inflation_annual=0.08, land=100_000_000.0)
b = _build_inflation(smr_inflation_annual=0.08, land=200_000_000.0)
cf_a = a.cashflow # type: ignore[attr-defined]
cf_b = b.cashflow # type: ignore[attr-defined]
# Месяц 0 — только земля (ПИР тоже в окне [0..6), но он одинаков в обоих вызовах).
# Разница cashflow[0] == разница земли × 1.0 (фактор t=0 == 1.0, земля не эскалирует).
assert cf_a[0] - cf_b[0] == pytest.approx(200_000_000.0 - 100_000_000.0, rel=1e-9)