From b1332d6b5598efd54e9a308f90a7927163b7452e Mon Sep 17 00:00:00 2001
From: Light1YT
Date: Tue, 23 Jun 2026 21:10:25 +0500
Subject: [PATCH] feat(generative): calibrate finmodel sale price from
Objective market data (epic #1881 PR-2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную
медиану из 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
---
backend/app/api/v1/concepts.py | 189 +++++++++++++++++-
backend/app/schemas/concept.py | 9 +
backend/app/services/generative/financial.py | 46 ++++-
backend/app/services/generative/geometry.py | 18 +-
backend/app/services/generative/placement.py | 31 ++-
.../services/generative/test_api_concepts.py | 33 +++
.../generative/test_market_price_lookup.py | 129 ++++++++++++
.../generative/test_teap_financial.py | 63 ++++++
.../concept/ConceptExportButtons.tsx | 8 +-
.../concept/ConceptVariantsResult.tsx | 17 +-
frontend/src/lib/api-types.ts | 17 +-
frontend/src/lib/concept-api.ts | 28 +++
12 files changed, 570 insertions(+), 18 deletions(-)
create mode 100644 backend/tests/services/generative/test_market_price_lookup.py
diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py
index 579212b3..3e5dda47 100644
--- a/backend/app/api/v1/concepts.py
+++ b/backend/app/api/v1/concepts.py
@@ -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
diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index 5b765519..31e3ed05 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -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"]
diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py
index 56bc5d9d..fe3228fa 100644
--- a/backend/app/services/generative/financial.py
+++ b/backend/app/services/generative/financial.py
@@ -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
diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py
index 7609aa7c..6c282950 100644
--- a/backend/app/services/generative/geometry.py
+++ b/backend/app/services/generative/geometry.py
@@ -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
diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py
index 55441b7b..e66edc7b 100644
--- a/backend/app/services/generative/placement.py
+++ b/backend/app/services/generative/placement.py
@@ -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(
diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py
index 16f59db2..9e22b941 100644
--- a/backend/tests/services/generative/test_api_concepts.py
+++ b/backend/tests/services/generative/test_api_concepts.py
@@ -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",
}
diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py
new file mode 100644
index 00000000..0ec05908
--- /dev/null
+++ b/backend/tests/services/generative/test_market_price_lookup.py
@@ -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"
diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py
index d48a8222..97921410 100644
--- a/backend/tests/services/generative/test_teap_financial.py
+++ b/backend/tests/services/generative/test_teap_financial.py
@@ -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
diff --git a/frontend/src/components/concept/ConceptExportButtons.tsx b/frontend/src/components/concept/ConceptExportButtons.tsx
index 1cd67405..93fdf761 100644
--- a/frontend/src/components/concept/ConceptExportButtons.tsx
+++ b/frontend/src/components/concept/ConceptExportButtons.tsx
@@ -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)],
];
}
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
index 6e5429bc..28428785 100644
--- a/frontend/src/components/concept/ConceptVariantsResult.tsx
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -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,
}}
/>
+
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
@@ -252,8 +261,12 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{financial.irr_is_proxy
? "— оценочный (аннуализированный чистый ROI), не настоящий IRR."
: "."}{" "}
- Цены и себестоимость — рыночные ориентиры, не калиброванная
- модель. Коммерческие и офисные площади не учитываются.
+ Цена продажи жилья —{" "}
+ {financial.price_is_calibrated
+ ? `калибрована по рынку (${priceSourceCaption(financial)})`
+ : "норматив класса (нет рыночных данных по участку)"}
+ ; себестоимость СМР и цена паркинга — нормативные ориентиры.
+ Коммерческие и офисные площади не учитываются.
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index 5f5f8a02..03945257 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -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
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
index f1ca6257..8bcd9a05 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -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 = {
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 ────────────────────────────────────────────────────
/**