Merge pull request 'feat(generative): calibrate finmodel sale price from Objective market (epic #1881 PR-2)' (#1883) from feat/finmodel-price-calibration into main
This commit is contained in:
commit
2276c2057b
12 changed files with 570 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
129
backend/tests/services/generative/test_market_price_lookup.py
Normal file
129
backend/tests/services/generative/test_market_price_lookup.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue