From 9e848cee435262f11750e2e38cc801abd33db59c Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 25 Jun 2026 16:53:32 +0500 Subject: [PATCH] feat(financial/dcf): dormant separate-index inflation capability (default OFF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire smr_inflation_annual / sale_inflation_annual into _build_monthly_cashflow (monthly-compounded escalation of СМР costs vs sale revenue), gated by module constants _SMR_INFLATION_ANNUAL / _SALE_INFLATION_ANNUAL = 0.0. At default the factor (1+0)**t == 1.0 → byte-identical cashflow → existing Σ cashflow == net_profit invariant and all 57 prior tests unchanged (deep-review verified IEEE-754 identity + proration-denominator guard). Land not escalated (t0 lump); marketing/VAT/tax prorated on escalated revenue (total preserved, only retimed). Capability разведено+протестировано, OFF until calibrated indices + a decision to reframe the headline today-money invariant (nominal DCF vs real БДР). No schema/API/codegen change. +10 tests (zero-diff, compounding, NPV monotonicity, land-excluded, nominal-Σ-diverges-when-on). Refs #1881 --- backend/app/services/generative/financial.py | 98 +++++++-- .../services/generative/test_financial_dcf.py | 197 ++++++++++++++++++ 2 files changed, 281 insertions(+), 14 deletions(-) diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index ad85d104..c29bb346 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -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. diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py index e16aeccb..fc8a89da 100644 --- a/backend/tests/services/generative/test_financial_dcf.py +++ b/backend/tests/services/generative/test_financial_dcf.py @@ -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)