gendesign/backend/app/services/site_finder/parcel_financial.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

304 lines
15 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.

"""Site Finder ↔ финмодель bridge — лёгкий ТЭП из buildability → DCF (PR-4, #1881).
Участок в ``/analyze`` несёт **buildability** (площадь + предельные параметры зоны
НСПД/ПЗЗ: КСИТ/max_far, % застройки, этажность) + рыночную цену + gate-вердикт, но
НЕ полную проектную концепцию (нет реальной расстановки секций). Чтобы дать
девелоперу ОРИЕНТИРОВОЧНУЮ финмодель прямо на карточке участка, синтезируем лёгкий
ТЭП из предельных параметров зоны и прогоняем его через готовый
:func:`compute_financial` (полноценный статический каскад + помесячный DCF, PR-1/2/3).
Обе функции **чистые** — без БД / LLM / внешних API. Данные участка прокидывает
caller (analyze-эндпоинт) уже извлечёнными. Результат — ``dict`` (``FinancialModel``
+ bridge-метаданные) или ``None``, когда считать нельзя (gate против, нет чисел зоны,
нет площади).
ВАЖНО — HONEST CAVEAT
---------------------
Это финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту, НЕ по реальной
концепции: ТЭП синтезирован из предельных параметров зоны; класс жилья и темп
продаж — рыночные/нормативные прокси; ``land_cost`` = кадастровая стоимость (не
рыночная); график фаз — типовой. Caveat кладётся в результат (ключ ``caveat``) и
обязан показываться в UI/PDF рядом с цифрами.
"""
from __future__ import annotations
import logging
import math
from typing import Any
from app.schemas.concept import TEAP
from app.services.generative.financial import compute_financial
# Переиспользуем нормативные константы ТЭП из Stage 1c (НЕ дублируем): эффективность
# площади, средний лот, норма парковки по классу. Имена private (`_`) — импортируем
# их напрямую как single-source-of-truth, чтобы синтез не разошёлся с compute_teap.
from app.services.generative.teap import (
_AVG_APARTMENT_SQM,
_EFFICIENCY_BY_CLASS,
_PARKING_PER_APARTMENT,
HousingClass,
)
logger = logging.getLogger(__name__)
# ── HEAVY caveat — обязателен в любом результате (UI/PDF MUST показать) ─────────
_FINANCIAL_CAVEAT: str = (
"ОРИЕНТИРОВОЧНАЯ финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту "
"НСПД/ПЗЗ (КСИТ/высота/%застройки), НЕ по реальной проектной концепции. "
"ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — "
"рыночные/нормативные прокси; land_cost = кадастровая стоимость (не рыночная); "
"график фаз — типовой."
)
# ── Класс жилья по цене продажи, руб/кв.м (рыночный прокси) ─────────────────────
# price < 125k → эконом · < 177k → комфорт · иначе → бизнес. None цены → комфорт
# (нейтральный дефолт массового сегмента ЕКБ).
_HOUSING_CLASS_ECONOM_MAX: float = 125_000.0
_HOUSING_CLASS_COMFORT_MAX: float = 177_000.0
# ── Тип застройки по этажности (длительность СМР в DCF-графике) ─────────────────
# <=4 → spot · 58 → mid_rise · >=9 → high_rise.
_DEV_TYPE_SPOT_MAX_FLOORS: int = 4
_DEV_TYPE_MID_RISE_MAX_FLOORS: int = 8
def _infer_housing_class(price_per_sqm: float | None) -> HousingClass:
"""Класс жилья из цены продажи (рыночный прокси). None → comfort (дефолт)."""
if price_per_sqm is None:
return "comfort"
if price_per_sqm < _HOUSING_CLASS_ECONOM_MAX:
return "econom"
if price_per_sqm < _HOUSING_CLASS_COMFORT_MAX:
return "comfort"
return "business"
def _infer_development_type(max_floors: int | None) -> str:
"""Тип застройки из этажности зоны. None → mid_rise (нейтральный дефолт)."""
if max_floors is None:
return "mid_rise"
if max_floors <= _DEV_TYPE_SPOT_MAX_FLOORS:
return "spot"
if max_floors <= _DEV_TYPE_MID_RISE_MAX_FLOORS:
return "mid_rise"
return "high_rise"
def synthesize_teap_from_buildability(
*,
area_m2: float | None,
max_far: float | None,
max_building_pct: float | None,
max_floors: int | None,
housing_class: HousingClass,
) -> TEAP | None:
"""Синтезировать лёгкий :class:`TEAP` из buildability-параметров зоны.
Формулы (максимальная застройка по градрегламенту):
* ``GFA`` (total_floor_area) = ``area × max_far``; если ``max_far`` нет, но есть
``max_building_pct`` + ``max_floors`` → ``GFA = area × (pct/100) × floors``.
* ``built`` (пятно) = ``area × (max_building_pct/100)``; если % нет →
оценка ``GFA / max_floors`` (или ``GFA`` при отсутствии этажности).
* ``residential`` = ``GFA × efficiency[class]`` (вычет МОП/тех по классу).
* ``apartments`` = ``floor(residential / avg_apt[class])``.
* ``parking`` = ``ceil(apartments × parking_norm[class])``.
* ``density`` = ``GFA / area`` (= ``max_far``).
Возвращает ``None``, если нет площади ИЛИ нельзя вычислить GFA (нет ни
``max_far``, ни пары ``max_building_pct`` + ``max_floors``).
"""
if area_m2 is None or area_m2 <= 0:
return None
# ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ───────────
gfa: float
if max_far is not None and max_far > 0:
gfa = area_m2 * max_far
elif (
max_building_pct is not None
and max_building_pct > 0
and max_floors is not None
and max_floors > 0
):
gfa = area_m2 * (max_building_pct / 100.0) * max_floors
else:
# Ни КСИТ, ни (%застройки + этажность) — GFA не вывести.
return None
# ── Пятно застройки (built) ────────────────────────────────────────────────
if max_building_pct is not None and max_building_pct > 0:
built_area = area_m2 * (max_building_pct / 100.0)
elif max_floors is not None and max_floors > 0:
# Нет %застройки → пятно ≈ GFA / этажность.
built_area = gfa / max_floors
else:
built_area = gfa
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
# Плотность застройки = FAR = GFA / площадь участка (= max_far, если он задан).
density = gfa / area_m2 if area_m2 > 0 else 0.0
return TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(gfa, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
def synthesize_parcel_financial(
*,
area_m2: float | None,
nspd_zoning: dict[str, Any] | None,
market_price: dict[str, Any] | None,
district_price_median: float | None,
cadastral_value_rub: float | None,
gate_verdict: dict[str, Any] | None,
velocity_sqm_per_month: float | None = None,
) -> dict[str, Any] | None:
"""Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``.
Gate-guard: считаем ТОЛЬКО когда gate допускает МКД и есть числа зоны + площадь.
Args:
area_m2: площадь участка, кв.м (из parcel_meta/EGRN или geometry_suitability).
nspd_zoning: блок зоны НСПД/ПЗЗ — берём ``max_far`` / ``max_building_pct`` /
``max_floors`` (merged regulation-поля из EKB-geoportal, #1067).
market_price: квартальная ценовая статистика (берём ``median``).
district_price_median: медиана цены по району, руб/кв.м (fallback к market).
cadastral_value_rub: кадастровая стоимость участка → land_cost (НЕ рыночная).
gate_verdict: can-build-MKD вердикт — гейт на расчёт.
velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового конкурента
района (``velocity.project_absorption_sqm_per_month``), м²/мес. Прокидывается
в :func:`compute_financial` как ``market_velocity_sqm_per_month`` → драйвит
окно продаж DCF. ``None`` → дефолт-30-мес график (``schedule_is_default``).
Returns:
``dict`` = ``FinancialModel.model_dump()`` + bridge-метаданные
(``housing_class_inferred`` / ``development_type_inferred`` / ``teap_synth`` /
``caveat`` / ``schedule_is_default``), либо ``None`` когда считать нельзя.
"""
# ── Gate-guard: только при разрешённом МКД ─────────────────────────────────
can_build = (gate_verdict or {}).get("can_build_mkd")
if can_build is not True:
# False (запрещено) или "unknown" (нет данных) — финмодель не строим.
return None
# ── Числа зоны обязательны ─────────────────────────────────────────────────
if not nspd_zoning:
return None
max_far = _as_float(nspd_zoning.get("max_far"))
max_building_pct = _as_float(nspd_zoning.get("max_building_pct"))
max_floors = _as_int(nspd_zoning.get("max_floors"))
# Нужен хотя бы КСИТ ИЛИ (%застройки + этажность) — иначе GFA не вывести.
if max_far is None and not (max_building_pct is not None and max_floors is not None):
return None
# ── Площадь обязательна ────────────────────────────────────────────────────
if area_m2 is None or area_m2 <= 0:
return None
# ── Цена продажи: market median → district median (рыночный прокси) ─────────
price: float | None = None
price_source = "class_norm"
if market_price and _as_float(market_price.get("median")) is not None:
price = _as_float(market_price.get("median"))
price_source = "objective_district_median"
elif district_price_median is not None:
price = float(district_price_median)
price_source = "district_reference"
housing_class = _infer_housing_class(price)
development_type = _infer_development_type(max_floors)
# ── Синтез ТЭП из предельных параметров зоны ───────────────────────────────
teap = synthesize_teap_from_buildability(
area_m2=area_m2,
max_far=max_far,
max_building_pct=max_building_pct,
max_floors=max_floors,
housing_class=housing_class,
)
if teap is None:
return None
# ── Полноценный статический каскад + помесячный DCF (PR-1/2/3) ──────────────
fin = compute_financial(
teap=teap,
housing_class=housing_class,
land_cost_rub=cadastral_value_rub,
market_price_per_sqm=price,
price_source=price_source,
development_type=development_type,
# RANK 1: окно продаж по рыночной абсорбции района (один типовой конкурент).
market_velocity_sqm_per_month=velocity_sqm_per_month,
)
logger.info(
"parcel financial bridge: area=%.0f far=%s floors=%s class=%s dev=%s "
"gfa=%.0f resid=%.0f apts=%d npv=%.0f irr=%.3f%s sales_dur=%sm sched=%s",
area_m2,
max_far,
max_floors,
housing_class,
development_type,
teap.total_floor_area_sqm,
teap.residential_area_sqm,
teap.apartments_count,
fin.npv_rub,
fin.irr,
" (proxy)" if fin.irr_is_proxy else "",
fin.sales_duration_months,
"default" if fin.schedule_is_default else "market",
)
return {
# Полный FinancialModel-каскад (revenue/cost/net/roi/margin/irr/npv/pbp + цена).
**fin.model_dump(),
# ── Bridge-метаданные (синтез из buildability) ─────────────────────────
"housing_class_inferred": housing_class,
"development_type_inferred": development_type,
"teap_synth": {
"residential_area_sqm": teap.residential_area_sqm,
"total_floor_area_sqm": teap.total_floor_area_sqm,
"parking_spaces": teap.parking_spaces,
"apartments_count": teap.apartments_count,
},
# HEAVY caveat — обязан показываться в UI/PDF рядом с цифрами.
"caveat": _FINANCIAL_CAVEAT,
}
def _as_float(value: Any) -> float | None:
"""Best-effort numeric coercion → ``float`` или ``None`` (нечисла/bool → None)."""
if value is None or isinstance(value, bool):
return None
try:
out = float(value)
except (ValueError, TypeError):
return None
return out if math.isfinite(out) else None
def _as_int(value: Any) -> int | None:
"""Best-effort numeric coercion → ``int`` или ``None`` (нечисла/bool → None)."""
f = _as_float(value)
return int(f) if f is not None else None
__all__ = [
"synthesize_parcel_financial",
"synthesize_teap_from_buildability",
]