gendesign/backend/app/services/generative/financial.py
Light1YT b1332d6b55
All checks were successful
CI / frontend-tests (pull_request) Successful in 1m3s
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 10m6s
feat(generative): calibrate finmodel sale price from Objective market data (epic #1881 PR-2)
Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную
медиану из Objective по локации участка. compute_financial остаётся ЧИСТЫМ
(без БД) — DB-lookup в API-слое, цена прокидывается параметром.

- compute_financial: +optional market_price_per_sqm/price_source; цена =
  рынок если есть, иначе класс-норма. Паркинг/СМР НЕ калибруем (только жильё).
- concepts.py _lookup_market_price(db, lon, lat): центроид → ближайший
  ekb_district (ST_DWithin 5км) → медиана objective_lots.price_per_m2_rub
  (n>=10, фильтр 30k-600k) → fallback ekb_districts.median_price_per_m2 →
  None. try/except → graceful (None, class_norm) при любой ошибке (вне ЕКБ/
  нет гео/SQL) без краха генерации. psycopg3 CAST.
- FinancialModel +price_per_sqm_used/price_is_calibrated/price_source (additive).
- Threading market_price через generate→placement→compute_financial (optional
  kwargs, backward-compat).
- UI/CSV: честный caption источника (рынок Objective / справочник района /
  норматив класса). Старый лживый footnote «не калиброванная модель» → условный.

Prod-verified: калибруется 4 главных ЕКБ-района по name-match (Академический
204k лотов, Ленинский 38k, Кировский, Орджоникидзевский); остальные 5 admin-
районов честно → district_reference. Гео-радиус matching (полное покрытие) —
follow-up.

api-types.ts регенерён авторитетно. mypy strict clean (generative.*), +14
тестов (калибровка/lookup 4 ветки/SQL-ошибки graceful/backward-compat).

Refs #1881
2026-06-23 21:10:25 +05:00

238 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Generative Design — Stage 1c: static developer financial model (PR-1, эпик #1881).
We combine **our areas** (from real geometry → :class:`TEAP`) with the **cost
completeness** of a reference developer Excel model: a full static cost cascade,
VAT, profit tax → net profit + ROI. This is a *correct static* P&L — there is no
DCF / discounting yet (that lands in PR-3); no new user inputs are required, only
norms + the existing TEAP.
Cascade (in :func:`compute_financial`):
* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price.
* **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
networks (ТУ) + developer services + contingency + marketing/realtor + land.
* **БДР / taxes** = gross margin VAT profit tax → net profit.
* **Metrics** = net ROI on cost, net margin on revenue, and an IRR *proxy*.
VAT — DOCUMENTED SIMPLIFICATION
-------------------------------
Exact НК РФ VAT mechanics (output VAT on flats vs. input-VAT deductions on
construction/materials, эскроу specifics, residential-sale exemptions) are out of
PR-1 scope. We use a conservative, honest approximation on the project's
*value-added* (gross margin):
vat = max(0, gross_margin) × VAT_RATE / (1 + VAT_RATE)
i.e. the embedded VAT inside the positive margin. This deliberately *does not*
model the full input/output deduction chain — it is a stand-in that keeps the net
P&L plausible and conservative. A loss-making project pays no VAT here. Replace
with proper output/input modelling when calibrating (PR-2/PR-3).
IRR — PROXY UNTIL DCF
---------------------
A real IRR needs a dated cashflow series (PR-3). We expose ``irr`` as an
annualised net-ROI proxy (``roi / _PROJECT_YEARS``, clamped to ±1) and flag it via
``irr_is_proxy=True`` so the frontend can show the caveat.
OFFICES / COMMERCIAL — SKIPPED
------------------------------
TEAP carries no office/commercial area (only residential + parking), so commercial
revenue/cost is intentionally **not** modelled here. Add when TEAP gains the field.
PRICE CALIBRATION — PR-2
------------------------
The housing **sale price** can be calibrated to real market data: callers pass
``market_price_per_sqm`` (руб/кв.м) computed from Objective listings / district
reference at the API layer. When ``None`` we fall back to the per-class norm and
flag the source honestly (``price_source="class_norm"``) so the UI/PDF never
passes a class norm off as a market figure. Construction cost (СМР) is *not*
calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms.
This function stays **pure**: it does no DB lookup itself (the API layer does the
lookup and passes the result in). Детерминированно, без LLM / внешних API / БД.
"""
from __future__ import annotations
import logging
from typing import Literal
from app.schemas.concept import TEAP, FinancialModel
logger = logging.getLogger(__name__)
HousingClass = Literal["econom", "comfort", "business"]
# ── Цена продажи жилья, руб/кв.м (proxy рынка ЕКБ/региона, упрощённо) ──────────
_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
"econom": 110_000.0,
"comfort": 145_000.0,
"business": 210_000.0,
}
# ── Себестоимость СМР, руб/кв.м общей площади (выше класс -> дороже отделка/инж) ─
_CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
"econom": 72_000.0,
"comfort": 88_000.0,
"business": 120_000.0,
}
# ── Паркинг (Excel-эталон девелоперской модели) ───────────────────────────────
# Цена продажи / себестоимость одного машиноместа, руб.
_PARKING_PRICE_PER_SPOT: float = 1_900_000.0 # Excel: цена продажи м/м
_PARKING_COST_PER_SPOT: float = 1_800_000.0 # Excel: себестоимость м/м
# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
# Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%.
_PIR_PCT_OF_CONSTRUCTION: float = 0.025
# Сети / подключение по ТУ, руб/кв.м GFA.
# Excel: ТУ 330М / 55042 кв.м ≈ 6000 руб/кв.м.
_NETWORKS_COST_PER_SQM: float = 6_000.0
# Услуги заказчика / управление как доля от СМР.
# Excel: 76.5М при СМР 6123М ≈ 1.25% -> округлённо 1.5%.
_DEVELOPER_SERVICES_PCT: float = 0.015
# Резерв / непредвиденные как доля от (СМР + ПИР + сети). Excel: 3%.
_CONTINGENCY_PCT: float = 0.03
# Реклама + риэлтор + маркетинг как доля от выручки. Excel: 7%.
_MARKETING_REALTOR_PCT: float = 0.07
# ── Налоги ────────────────────────────────────────────────────────────────────
# НДС, ставка. РФ стандартная 20%.
_VAT_RATE: float = 0.20
# Налог на прибыль организаций. С 01.01.2025 базовая ставка повышена 20% -> 25%
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
_PROFIT_TAX_RATE: float = 0.25
# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy.
_PROJECT_YEARS: float = 3.0
def compute_financial(
*,
teap: TEAP,
housing_class: HousingClass,
land_cost_rub: float | None,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`.
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
налогом на прибыль → чистая прибыль + ROI. НЕ DCF (PR-3); НДС — упрощение
(см. docstring модуля); офисов нет в TEAP — пропущены.
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
Args:
teap: Stage 1c ТЭП (residential_area_sqm, total_floor_area_sqm, parking_spaces).
housing_class: задаёт цену продажи (fallback) и себестоимость СМР.
land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
market_price_per_sqm: калиброванная рыночная цена продажи жилья, руб/кв.м.
``None`` → fallback на норматив класса ``_SALE_PRICE_PER_SQM``. Калибруем
ТОЛЬКО цену жилья; цена паркинга и себестоимость СМР остаются нормативом.
price_source: метка источника цены для honest-флага в UI/PDF — одно из
"objective_district_median" / "district_reference" / "class_norm".
Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``.
"""
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback
# на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
if market_price_per_sqm is not None:
sale_price = market_price_per_sqm
price_is_calibrated = True
resolved_price_source = price_source
else:
sale_price = _SALE_PRICE_PER_SQM[housing_class]
price_is_calibrated = False
resolved_price_source = "class_norm"
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
revenue_residential = teap.residential_area_sqm * sale_price
revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT
revenue = revenue_residential + revenue_parking
# ── Затраты (каскад) ───────────────────────────────────────────────────────
construction_resid = teap.total_floor_area_sqm * construction_cost
construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT
construction = construction_resid + construction_parking
pir = construction * _PIR_PCT_OF_CONSTRUCTION
networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
dev_services = construction * _DEVELOPER_SERVICES_PCT
contingency = (construction + pir + networks) * _CONTINGENCY_PCT
marketing = revenue * _MARKETING_REALTOR_PCT
land = land_cost_rub if land_cost_rub is not None else 0.0
cost = construction + pir + networks + dev_services + contingency + marketing + land
# ── БДР / налоги → чистая прибыль ─────────────────────────────────────────
gross_margin = revenue - cost # валовая (как было)
# НДС: УПРОЩЕНО — встроенный НДС в положительной марже (value-added),
# не полная механика входного/выходного НДС НК РФ (см. docstring модуля).
vat = max(0.0, gross_margin) * _VAT_RATE / (1.0 + _VAT_RATE)
profit_before_tax = gross_margin - vat
profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE
net_profit = profit_before_tax - profit_tax
# ── Метрики ────────────────────────────────────────────────────────────────
roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты
margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку
# IRR-proxy: аннуализированный net-ROI. НЕ настоящий IRR (нет дат/дисконта —
# PR-3). Клампинг ±1, чтобы поле было монотонным и читаемым; флаг proxy.
irr = roi / _PROJECT_YEARS
irr = max(-1.0, min(1.0, irr))
model = FinancialModel(
# legacy summary
revenue_rub=round(revenue, 2),
cost_rub=round(cost, 2),
gross_margin_rub=round(gross_margin, 2),
irr=round(irr, 4),
# revenue breakdown
revenue_residential_rub=round(revenue_residential, 2),
revenue_parking_rub=round(revenue_parking, 2),
# cost cascade
construction_rub=round(construction, 2),
pir_rub=round(pir, 2),
networks_rub=round(networks, 2),
developer_services_rub=round(dev_services, 2),
contingency_rub=round(contingency, 2),
marketing_rub=round(marketing, 2),
land_rub=round(land, 2),
# БДР / taxes
vat_rub=round(vat, 2),
profit_before_tax_rub=round(profit_before_tax, 2),
profit_tax_rub=round(profit_tax, 2),
net_profit_rub=round(net_profit, 2),
# metrics
roi=round(roi, 4),
margin_pct=round(margin_pct, 4),
irr_is_proxy=True,
# price calibration (PR-2)
price_per_sqm_used=round(sale_price, 2),
price_is_calibrated=price_is_calibrated,
price_source=resolved_price_source,
)
logger.info(
"financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f "
"price=%.0f/m2 source=%s",
model.revenue_rub,
model.cost_rub,
model.gross_margin_rub,
model.vat_rub,
model.profit_tax_rub,
model.net_profit_rub,
model.roi,
model.price_per_sqm_used,
model.price_source,
)
return model
__all__ = ["HousingClass", "compute_financial"]