gendesign/backend/app/schemas/concept.py
Light1YT b6b9aa9929
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m10s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 10m10s
feat(financial): DCF-график продаж по реальному темпу поглощения (rank 1)
Финмодель брала фиксированное окно продаж 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>
2026-06-24 02:56:48 +05:00

130 lines
7.3 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.

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]