Финмодель брала фиксированное окно продаж 30 мес независимо от рынка (schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа поглощения, который уже вычисляется в том же /analyze, но финмодель его игнорировала. Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1) (темп одного типичного локального продавца) — иначе модель считала бы, что новый проект забирает весь рыночный темп (дико оптимистично). Поле project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь); rosreestr-fallback и вырожденные пути → None (поквартальный count без по-проектной декомпозиции не может задавать график). financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён: sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit держится (перенос выручки во времени не меняет сумму). schedule_is_default флипается в false когда график рыночный; новое поле sales_duration_months (реализованное окно) для UI/PDF. Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) → compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока передаёт None (out of scope, follow-up). Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу; non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 — ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed. ruff+mypy(strict financial.py) чисто. api-types перегенерены. Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
7.3 KiB
Python
130 lines
7.3 KiB
Python
from typing import Any, Literal
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class ConceptInput(BaseModel):
|
||
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
||
|
||
parcel_geojson: dict[str, Any] = Field(
|
||
..., description="GeoJSON Polygon of the parcel (WGS84 / EPSG:4326)"
|
||
)
|
||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||
target_floors: int = Field(9, ge=1, le=30)
|
||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||
land_cost_rub: float | None = Field(
|
||
None, ge=0, description="Optional land cost for financial model"
|
||
)
|
||
|
||
|
||
class TEAP(BaseModel):
|
||
"""Технико-экономические показатели."""
|
||
|
||
built_area_sqm: float
|
||
total_floor_area_sqm: float
|
||
residential_area_sqm: float
|
||
apartments_count: int
|
||
density: float
|
||
parking_spaces: int
|
||
|
||
|
||
class FinancialModel(BaseModel):
|
||
"""Static developer P&L + monthly-DCF investment metrics (PR-1/PR-3, эпик #1881).
|
||
|
||
Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top
|
||
of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule**
|
||
(default phase norms — ПИР → СМР → продажи) and runs a real DCF: NPV, IRR
|
||
(bisection over the monthly cashflow) and *undiscounted* cumulative payback.
|
||
See ``compute_financial`` docstring for the schedule assumptions and the
|
||
налоги/рассрочка phasing choices.
|
||
|
||
``irr`` is a **real** annualised DCF IRR when ``irr_is_proxy is False``; for a
|
||
degenerate cashflow with no sign change (e.g. always-loss) the bisection cannot
|
||
bracket a root, so we fall back to the PR-1 annualised-ROI proxy and flag
|
||
``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not
|
||
exact НК РФ mechanics.
|
||
|
||
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
|
||
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
|
||
expose the full cascade, the net P&L and the DCF metrics.
|
||
"""
|
||
|
||
# ── Legacy summary (backward-compat) ───────────────────────────────────────
|
||
revenue_rub: float
|
||
cost_rub: float
|
||
gross_margin_rub: float
|
||
irr: float
|
||
|
||
# ── Revenue breakdown ──────────────────────────────────────────────────────
|
||
revenue_residential_rub: float
|
||
revenue_parking_rub: float
|
||
|
||
# ── Cost cascade ───────────────────────────────────────────────────────────
|
||
construction_rub: float
|
||
pir_rub: float
|
||
networks_rub: float
|
||
developer_services_rub: float
|
||
contingency_rub: float
|
||
marketing_rub: float
|
||
land_rub: float
|
||
|
||
# ── БДР / taxes → net profit ───────────────────────────────────────────────
|
||
vat_rub: float
|
||
profit_before_tax_rub: float
|
||
profit_tax_rub: float
|
||
net_profit_rub: float
|
||
|
||
# ── Metrics ────────────────────────────────────────────────────────────────
|
||
roi: float # net profit / total cost
|
||
margin_pct: float # net profit / revenue
|
||
irr_is_proxy: bool = True # frontend caveat: True → IRR is the ROI-proxy, not DCF
|
||
|
||
# ── DCF / investment metrics (PR-3, эпик #1881) ────────────────────────────
|
||
# NPV дисконтированного помесячного cashflow по ставке ``discount_rate_used``.
|
||
npv_rub: float = 0.0
|
||
# Окупаемость (мес) по НЕдисконтированному накопительному cashflow, линейная
|
||
# интерполяция дробного месяца. ``None`` — проект не окупается на горизонте.
|
||
payback_months: float | None = None
|
||
# Годовая ставка дисконтирования, фактически применённая в NPV (норматив).
|
||
discount_rate_used: float = 0.0
|
||
# График фаз/продаж — типовые нормативные ДОПУЩЕНИЯ (не график конкретного
|
||
# проекта). True → UI/PDF показывают caveat «график — типовое допущение». RANK 1:
|
||
# False, когда окно продаж построено по реальной рыночной скорости абсорбции
|
||
# района (а не дефолт-30-мес).
|
||
schedule_is_default: bool = True
|
||
# RANK 1: реализованное окно продаж (sales_end − sales_start), мес — для UI/PDF.
|
||
# Драйвится рыночной абсорбцией района, когда ``schedule_is_default is False``;
|
||
# иначе дефолт-норматив (с эскроу-привязкой к завершению стройки). ``None`` —
|
||
# финмодель не строилась/окно неизвестно.
|
||
sales_duration_months: float | None = None
|
||
|
||
# ── Financing overlay (PR-5, эпик #1881) ───────────────────────────────────
|
||
# OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты
|
||
# капитализируются (compound). Unlevered NPV/IRR (headline) НЕ затронуты.
|
||
financing_enabled: bool = False # True когда overlay посчитан
|
||
annual_rate_used: float = 0.0 # _CREDIT_RATE_ANNUAL (норматив, отдельно от дисконта)
|
||
peak_debt_rub: float = 0.0 # «задолженность по кредиту» Excel — пик долга
|
||
total_interest_rub: float = 0.0 # «поток по финдеятельности» Excel — Σ процентов
|
||
net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest
|
||
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
||
financing_is_simplified: bool = True
|
||
|
||
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||
# калиброванная по рынку (Objective / district reference), либо норматив класса.
|
||
# ``price_source`` honest-flag для UI/PDF: не выдавать норматив за рынок.
|
||
price_per_sqm_used: float
|
||
price_is_calibrated: bool = False
|
||
# Значения: "objective_district_median" | "district_reference" | "class_norm".
|
||
price_source: str = "class_norm"
|
||
|
||
|
||
class ConceptVariant(BaseModel):
|
||
strategy: Literal["max_area", "max_insolation", "balanced"]
|
||
buildings_geojson: dict[str, Any]
|
||
teap: TEAP
|
||
financial: FinancialModel
|
||
|
||
|
||
class ConceptOutput(BaseModel):
|
||
variants: list[ConceptVariant]
|