Merge pull request 'feat(generative): calibrate finmodel sale price from Objective market (epic #1881 PR-2)' (#1883) from feat/finmodel-price-calibration into main
All checks were successful
Deploy / build-frontend (push) Successful in 3m32s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m3s
Deploy / build-worker (push) Successful in 3m29s
Deploy / deploy (push) Successful in 1m27s

This commit is contained in:
bot-backend 2026-06-23 16:21:15 +00:00
commit 2276c2057b
12 changed files with 570 additions and 18 deletions

View file

@ -1,32 +1,211 @@
import logging
from typing import Annotated
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from shapely.geometry import Point
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.concept import ConceptInput, ConceptOutput
from app.services.generative import geometry
from app.services.generative.geometry import ParcelGeometryError
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
logger = logging.getLogger(__name__)
router = APIRouter()
# Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья.
# n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и
# выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на
# справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её
# отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других
# district-агрегатов проекта (см. parcels.py district_price_block).
_MIN_OBJECTIVE_SAMPLE: int = 10
# Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из
# parcels.py district-context — участок может лежать у границы полигона района.
_DISTRICT_SEARCH_RADIUS_M: int = 5000
# Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective
# (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py.
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
_DISTRICT_FOR_POINT_SQL = text(
"""
SELECT d.district_name,
d.median_price_per_m2
FROM ekb_districts d
WHERE d.geom IS NOT NULL
AND ST_DWithin(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
CAST(:radius AS double precision)
)
ORDER BY ST_Distance(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
) ASC
LIMIT 1
"""
)
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
_OBJECTIVE_MEDIAN_SQL = text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
"""
)
def _parcel_centroid_wkt(payload: ConceptInput) -> str:
"""Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup."""
polygon = _parse_polygon(payload.parcel_geojson)
c = polygon.centroid
wkt: str = Point(c.x, c.y).wkt
return wkt
def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]:
"""Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник.
Алгоритм (от точного к общему):
1. Центроид ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км).
2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE)
("objective_district_median"). Свежайший рыночный сигнал.
3. Иначе справочная ekb_districts.median_price_per_m2 ("district_reference").
4. Иначе (нет района / нет данных) (None, "class_norm") финмодель возьмёт
норматив класса.
Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой
БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm").
Read-only SAVEPOINT не нужен.
"""
try:
district_row = (
db.execute(
_DISTRICT_FOR_POINT_SQL,
{"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc)
return None, "class_norm"
if not district_row or not district_row["district_name"]:
logger.info("concept market-price: parcel outside ЕКБ districts → class_norm")
return None, "class_norm"
district_name: str = district_row["district_name"]
district_reference = district_row["median_price_per_m2"]
# 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог).
try:
obj_row = (
db.execute(
_OBJECTIVE_MEDIAN_SQL,
{"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning(
"concept market-price: objective median failed (district=%s), "
"trying district reference: %s",
district_name,
exc,
)
obj_row = None
if (
obj_row
and obj_row["sample_size"]
and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE
and obj_row["median_ppm2"]
):
price = float(obj_row["median_ppm2"])
logger.info(
"concept market-price: objective median %.0f руб/м² (district=%s, n=%d)",
price,
district_name,
int(obj_row["sample_size"]),
)
return price, "objective_district_median"
# 3) Справочная медиана района (24-мес окно ekb_districts).
if district_reference is not None:
price = float(district_reference)
logger.info(
"concept market-price: district reference %.0f руб/м² (district=%s, n<%d)",
price,
district_name,
_MIN_OBJECTIVE_SAMPLE,
)
return price, "district_reference"
logger.info(
"concept market-price: district=%s has no price reference → class_norm", district_name
)
return None, "class_norm"
@router.post("", response_model=ConceptOutput)
async def create_concept(payload: ConceptInput) -> ConceptOutput:
async def create_concept(
payload: ConceptInput,
db: Annotated[Session, Depends(get_db)],
) -> ConceptOutput:
"""Generate 3 building variants for the given parcel polygon.
Stage 1a: Shapely parse + buildable area (setback) + placement grid.
Stage 1b: greedy section placement with STRtree collisions (3 strategies).
Stage 1c: real ТЭП + financial model attached to each variant.
Stage 1c: real ТЭП + financial model attached to each variant. The housing
sale price is calibrated to real market data (Objective / district reference)
when available, falling back to the per-class norm with an honest source flag
(PR-2, эпик #1881).
A degenerate parcel (setback consumes everything, malformed geometry) yields a
422 rather than empty variants that is a bad request, not a valid empty result.
"""
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
market_price: float | None = None
price_source: str = "class_norm"
try:
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, wkt_point
)
except ParcelGeometryError:
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
pass
except Exception as exc:
# Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500.
logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc)
try:
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
variants = await run_in_threadpool(geometry.generate, payload)
variants = await run_in_threadpool(
geometry.generate,
payload,
market_price_per_sqm=market_price,
price_source=price_source,
)
except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc

View file

@ -70,6 +70,15 @@ class FinancialModel(BaseModel):
margin_pct: float # net profit / revenue
irr_is_proxy: bool = True # frontend caveat: not a real DCF IRR (см. PR-3)
# ── 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"]

View file

@ -39,8 +39,17 @@ 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.
Prices/costs of housing & СМР stay as the existing hardcoded per-class proxies
(calibration is PR-2). Детерминированно, без LLM / внешних API / БД.
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
@ -108,6 +117,8 @@ 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`.
@ -116,12 +127,30 @@ def compute_financial(
налогом на прибыль чистая прибыль + 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: задаёт цену продажи и себестоимость СМР.
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``.
"""
sale_price = _SALE_PRICE_PER_SQM[housing_class]
# Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный 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) ──────────────────────────────────────────────────────────
@ -185,9 +214,14 @@ def compute_financial(
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",
"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,
@ -195,6 +229,8 @@ def compute_financial(
model.profit_tax_rub,
model.net_profit_rub,
model.roi,
model.price_per_sqm_used,
model.price_source,
)
return model

View file

@ -291,18 +291,32 @@ def parse_parcel(
)
def generate(payload: ConceptInput) -> list[ConceptVariant]:
def generate(
payload: ConceptInput,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> list[ConceptVariant]:
"""Public orchestrator: Stage 1a -> 1b -> 1c -> 3 filled :class:`ConceptVariant`.
Deterministic end-to-end. On a degenerate parcel (setback eats everything, bad
geometry) we *log and re-raise* :class:`ParcelGeometryError` the API layer maps
it to 4xx; silently returning zero-variants would hide a bad request.
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены продажи
жилья, PR-2) прокидываются в Stage 1c (финмодель). Lookup рынка делает API-слой
(он один знает БД); сюда приходит уже посчитанная цена. ``None`` норматив класса.
"""
# Local import to avoid a module-level import cycle (placement imports geometry).
from app.services.generative import placement
parcel = parse_parcel(payload)
variants = placement.place_all_strategies(parcel, payload)
variants = placement.place_all_strategies(
parcel,
payload,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
logger.info("generated %d concept variants", len(variants))
return variants

View file

@ -191,6 +191,9 @@ def place_strategy(
parcel: Parcel,
payload: ConceptInput,
spec: StrategySpec,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> ConceptVariant | None:
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
@ -198,6 +201,10 @@ def place_strategy(
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
``market_price_per_sqm`` / ``price_source`` прокидываются в :func:`compute_financial`
для калибровки цены продажи жилья по рынку (PR-2). Lookup делает API-слой; здесь
только проброс. ``None`` норматив класса.
"""
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
@ -221,6 +228,8 @@ def place_strategy(
teap=teap_result,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec)
@ -233,18 +242,36 @@ def place_strategy(
)
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
def place_all_strategies(
parcel: Parcel,
payload: ConceptInput,
*,
market_price_per_sqm: float | None = None,
price_source: str = "class_norm",
) -> list[ConceptVariant]:
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
Вырожденные стратегии (нулевое размещение) отбраковываются в результат попадают
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
:class:`ParcelGeometryError` (API мапит в 422) лучше отказ, чем пустой/лживый ответ.
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
прокидываются неизменными в каждую стратегию цена едина для участка.
"""
variants = [
variant
for spec in _STRATEGIES
if (variant := place_strategy(parcel, payload, spec)) is not None
if (
variant := place_strategy(
parcel,
payload,
spec,
market_price_per_sqm=market_price_per_sqm,
price_source=price_source,
)
)
is not None
]
if not variants:
raise ParcelGeometryError(

View file

@ -7,10 +7,35 @@ degenerate parcel yields 422 rather than empty variants.
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
class _NoDbSession:
"""Заглушка Session: любой execute падает → market-price lookup деградирует в
class_norm без реальной БД. Тесты API не должны зависеть от живого Postgres."""
def execute(self, *args: object, **kwargs: object) -> object:
raise RuntimeError("no DB in unit test")
@pytest.fixture(autouse=True)
def _override_db() -> Iterator[None]:
"""get_db → заглушка без БД на время API-тестов концепции (conftest чистит overrides)."""
def _fake_get_db() -> Iterator[_NoDbSession]:
yield _NoDbSession()
app.dependency_overrides[get_db] = _fake_get_db
yield
app.dependency_overrides.pop(get_db, None)
_PARCEL = {
"type": "Polygon",
"coordinates": [
@ -57,6 +82,10 @@ def test_concepts_returns_three_filled_variants() -> None:
assert teap["parking_spaces"] > 0
assert fin["revenue_rub"] > 0
assert fin["cost_rub"] > 0
# PR-2: без БД market-price lookup честно падает в норматив класса.
assert fin["price_is_calibrated"] is False
assert fin["price_source"] == "class_norm"
assert fin["price_per_sqm_used"] > 0
# GeoJSON застройки непустой.
fc = v["buildings_geojson"]
assert fc["type"] == "FeatureCollection"
@ -133,4 +162,8 @@ def test_concepts_response_matches_contract_keys() -> None:
"roi",
"margin_pct",
"irr_is_proxy",
# price calibration (PR-2)
"price_per_sqm_used",
"price_is_calibrated",
"price_source",
}

View file

@ -0,0 +1,129 @@
"""PR-2 (эпик #1881) — unit tests for the market-price lookup in the concept API.
``_lookup_market_price`` is the only DB-touching piece of the price-calibration
chain (``compute_financial`` itself stays pure). We mock the SQLAlchemy session so
the tests assert the SELECTION LOGIC objective median vs district reference vs
class norm and the graceful-degradation contract (never raise), without a live DB.
"""
from __future__ import annotations
from typing import Any
from app.api.v1 import concepts
class _Result:
"""Мини-заглушка результата execute(): .mappings().first() → заданный row."""
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return self._row
class _FakeSession:
"""Сессия, отдающая заранее заданные строки в порядке вызовов execute().
``rows`` список (row-dict | None | Exception). Exception execute поднимает её
(моделируем сбой конкретного запроса). Порядок: [district_lookup, objective_median].
"""
def __init__(self, rows: list[dict[str, Any] | None | Exception]) -> None:
self._rows = list(rows)
self.calls = 0
def execute(self, *_args: object, **_kwargs: object) -> _Result:
item = self._rows[self.calls] if self.calls < len(self._rows) else None
self.calls += 1
if isinstance(item, Exception):
raise item
return _Result(item)
_WKT = "POINT (60.6 56.83)"
def test_objective_median_selected_when_sample_large_enough() -> None:
# Район найден + Objective n>=10 → выбирается медиана Objective.
db = _FakeSession(
[
{"district_name": "Кировский", "median_price_per_m2": 130_000},
{"median_ppm2": 175_000.0, "sample_size": 42},
]
)
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price == 175_000.0
assert source == "objective_district_median"
def test_district_reference_when_objective_sample_too_small() -> None:
# Район найден, но Objective n<10 → справочная медиана района.
db = _FakeSession(
[
{"district_name": "Кировский", "median_price_per_m2": 130_000},
{"median_ppm2": 175_000.0, "sample_size": 4},
]
)
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price == 130_000.0
assert source == "district_reference"
def test_district_reference_when_no_objective_rows() -> None:
# Район найден, Objective вернул пусто (n=0/None) → справочная медиана.
db = _FakeSession(
[
{"district_name": "Кировский", "median_price_per_m2": 128_500},
{"median_ppm2": None, "sample_size": 0},
]
)
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price == 128_500.0
assert source == "district_reference"
def test_class_norm_when_district_has_no_reference_and_no_objective() -> None:
# Район найден, но ни Objective (n<порог), ни справочной цены нет → class_norm.
db = _FakeSession(
[
{"district_name": "Кировский", "median_price_per_m2": None},
{"median_ppm2": None, "sample_size": 2},
]
)
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price is None
assert source == "class_norm"
def test_class_norm_when_parcel_outside_ekb() -> None:
# Центроид вне 8 полигонов ЕКБ (район не найден) → class_norm, без обращения к Objective.
db = _FakeSession([None])
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price is None
assert source == "class_norm"
def test_class_norm_when_district_query_raises() -> None:
# SQL-ошибка на district-lookup (нет PostGIS/таблицы) → graceful class_norm, без краха.
db = _FakeSession([RuntimeError("relation ekb_districts does not exist")])
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price is None
assert source == "class_norm"
def test_district_reference_when_objective_query_raises() -> None:
# District найден, но objective-median падает → откат на справочную цену района.
db = _FakeSession(
[
{"district_name": "Кировский", "median_price_per_m2": 140_000},
RuntimeError("objective_lots query failed"),
]
)
price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type]
assert price == 140_000.0
assert source == "district_reference"

View file

@ -187,3 +187,66 @@ def test_financial_backward_compat_fields_present() -> None:
assert isinstance(model.cost_rub, float)
assert isinstance(model.gross_margin_rub, float)
assert isinstance(model.irr, float)
# ── PR-2: рыночная калибровка цены продажи жилья ───────────────────────────────
def test_financial_no_market_price_uses_class_norm() -> None:
# Без market_price_per_sqm — норматив класса comfort (145_000), флаг не калибровано.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.price_per_sqm_used == 145_000.0
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
# Выручка жилья считается по нормативу.
assert model.revenue_residential_rub == 1000.0 * 145_000.0
def test_financial_market_price_overrides_class_norm() -> None:
# market_price_per_sqm передан → используется он, флаг калибровано, source проброшен.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=180_000.0,
price_source="objective_district_median",
)
assert model.price_per_sqm_used == 180_000.0
assert model.price_is_calibrated is True
assert model.price_source == "objective_district_median"
# Выручка жилья считается по рыночной цене, НЕ по нормативу класса.
assert model.revenue_residential_rub == 1000.0 * 180_000.0
def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None:
# Калибруем ТОЛЬКО цену жилья: паркинг (1.9М) и себестоимость СМР (88_000) — норма.
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=200_000.0,
price_source="district_reference",
)
# Паркинг — норматив (10 * 1_900_000), не зависит от рыночной цены жилья.
assert model.revenue_parking_rub == 10 * 1_900_000.0
# СМР — норматив класса comfort (GFA*88_000 + паркинг*1_800_000).
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0
def test_financial_none_market_price_forces_class_norm_source() -> None:
# Даже если source передан, при market_price_per_sqm=None он форсится в class_norm —
# нельзя выдавать норматив за рыночные данные.
t = _teap(residential=1000.0, gfa=1300.0)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=None,
price_source="objective_district_median",
)
assert model.price_is_calibrated is False
assert model.price_source == "class_norm"
assert model.price_per_sqm_used == 145_000.0

View file

@ -19,7 +19,11 @@ import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import { STRATEGY_LABELS, type ConceptVariant } from "@/lib/concept-api";
import {
priceSourceCaption,
STRATEGY_LABELS,
type ConceptVariant,
} from "@/lib/concept-api";
// ── CSV helpers (mirrors site-finder ExportButtons) ───────────────────────────
@ -54,6 +58,8 @@ function buildCsvRows(variant: ConceptVariant): string[][] {
["Затраты, ₽", String(f.cost_rub)],
["Валовая прибыль, ₽", String(f.gross_margin_rub)],
["IRR", String(f.irr)],
["Цена продажи жилья, ₽/м²", String(f.price_per_sqm_used)],
["Источник цены", priceSourceCaption(f)],
];
}

View file

@ -13,6 +13,7 @@ import { KpiCard } from "@/components/analytics/KpiCard";
import { Section } from "@/components/analytics/Section";
import { Badge } from "@/components/ui/Badge";
import {
priceSourceCaption,
STRATEGY_HINTS,
STRATEGY_LABELS,
type ConceptVariant,
@ -222,6 +223,14 @@ function VariantPanel({ parcel, variant }: PanelProps) {
positive: null,
}}
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{
value: priceSourceCaption(financial),
positive: null,
}}
/>
</div>
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
@ -252,8 +261,12 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{financial.irr_is_proxy
? "— оценочный (аннуализированный чистый ROI), не настоящий IRR."
: "."}{" "}
Цены и себестоимость рыночные ориентиры, не калиброванная
модель. Коммерческие и офисные площади не учитываются.
Цена продажи жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
</p>
</div>
</details>

View file

@ -19,7 +19,10 @@ export interface paths {
*
* Stage 1a: Shapely parse + buildable area (setback) + placement grid.
* Stage 1b: greedy section placement with STRtree collisions (3 strategies).
* Stage 1c: real ТЭП + financial model attached to each variant.
* Stage 1c: real ТЭП + financial model attached to each variant. The housing
* sale price is calibrated to real market data (Objective / district reference)
* when available, falling back to the per-class norm with an honest source flag
* (PR-2, эпик #1881).
*
* A degenerate parcel (setback consumes everything, malformed geometry) yields a
* 422 rather than empty variants that is a bad request, not a valid empty result.
@ -3540,6 +3543,18 @@ export interface components {
* @default true
*/
irr_is_proxy: boolean;
/** Price Per Sqm Used */
price_per_sqm_used: number;
/**
* Price Is Calibrated
* @default false
*/
price_is_calibrated: boolean;
/**
* Price Source
* @default class_norm
*/
price_source: string;
};
/**
* GroundedIn

View file

@ -74,8 +74,20 @@ export interface FinancialModel {
roi: number;
margin_pct: number;
irr_is_proxy: boolean;
// Price calibration (PR-2, эпик #1881)
/** Цена продажи жилья, ₽/м², фактически использованная в выручке. */
price_per_sqm_used: number;
/** true — калибровано по рынку; false — норматив класса. */
price_is_calibrated: boolean;
/** "objective_district_median" | "district_reference" | "class_norm". */
price_source: PriceSource;
}
export type PriceSource =
| "objective_district_median"
| "district_reference"
| "class_norm";
export interface ConceptVariant {
strategy: ConceptStrategy;
/** FeatureCollection полигонов зданий (WGS84). */
@ -114,6 +126,22 @@ export const DEVELOPMENT_TYPE_LABELS: Record<DevelopmentType, string> = {
high_rise: "Высотная",
};
/**
* Honest caption for the sale price source (PR-2). Never passes a class norm off
* as a market figure when the price is not calibrated the caption says so.
*/
export function priceSourceCaption(financial: FinancialModel): string {
switch (financial.price_source) {
case "objective_district_median":
return "рынок: медиана объявлений Objective по району";
case "district_reference":
return "рынок: справочная медиана района (нет свежей выборки Objective)";
case "class_norm":
default:
return "норматив класса (нет рыночных данных по участку)";
}
}
// ── Hook: useCreateConcept ────────────────────────────────────────────────────
/**