Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную медиану из 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
238 lines
13 KiB
Python
238 lines
13 KiB
Python
"""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"]
|