diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 1488cb92..b24249fd 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -134,6 +134,17 @@ def _wind_label(deg: float) -> str: EKB_CENTER_LAT: float = 56.838011 EKB_CENTER_LON: float = 60.597474 +# ── Гео-радиусная калибровка цены продажи (geo_radius_price) ──────────────────── +# Покрывает пробел name-match district_reference: только 4 из 9 админ-районов ЕКБ +# матчатся с Objective-данными по имени, остальные 5 проваливались в district_reference/ +# class_norm без реальной гео-цены. Берём медиану price_per_m2_rub новостроек Objective +# (objective_lots) в радиусе вокруг центроида участка (коорды — через complexes.lat/lon). +_GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиус сопоставимых ЖК (ЕКБ) +# Минимум лотов для устойчивой медианы — зеркалит порог n≥10 квартальной MV +# (mv_quarter_price_per_m2). Дополнительно требуем ≥2 ЖК, чтобы один ЖК не доминировал. +_GEO_PRICE_MIN_LOTS: int = 10 +_GEO_PRICE_MIN_COMPLEXES: int = 2 + # Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40. SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0} SCORE_MAX_REFERENCE: float = 40.0 @@ -1820,6 +1831,9 @@ def analyze_parcel( # Defensive: centroid_row может существовать, но с NULL lat/lon (вырожденная/ # невалидная геометрия → ST_Centroid NULL). Проверяем именно значения, а не только # наличие строки — иначе float(None) → 500. Fallback = центр ЕКБ. + centroid_is_fallback: bool = ( + centroid_row is None or centroid_row["lat"] is None or centroid_row["lon"] is None + ) centroid_lat: float = ( float(centroid_row["lat"]) if centroid_row and centroid_row["lat"] is not None else 56.838 ) @@ -2691,6 +2705,100 @@ def analyze_parcel( logger.warning("market_price query failed for %s: %s", cad_num, e) market_price = {"deals_count": 0, "source": "no_data"} + # 10d-pre1b) Гео-радиусная цена — медиана price_per_m2_rub новостроек Objective в + # радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference: + # только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё + # проваливались в class_norm. У objective_lots нет geom — коорды берём через + # complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py). + geo_radius_price: dict[str, Any] + # Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная + # медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную + # цену КОНКРЕТНОГО участка (price_is_calibrated=true). Это вводит в заблуждение — + # пропускаем запрос, цена честно падает в district/class_norm. + if not centroid_is_fallback: + try: + with db.begin_nested(): + grp_row = ( + db.execute( + text(""" + SELECT + percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) + AS median, + count(*) AS n, + count(DISTINCT c.id) AS n_complexes + FROM complexes c + JOIN objective_lots ol + ON ol.complex_id = c.id + AND ol.price_per_m2_rub IS NOT NULL + WHERE c.latitude IS NOT NULL + AND c.longitude IS NOT NULL + AND ST_DWithin( + ST_SetSRID( + ST_MakePoint(c.longitude, c.latitude), 4326 + )::geography, + ST_SetSRID( + ST_MakePoint( + CAST(:lon AS float), CAST(:lat AS float) + ), 4326 + )::geography, + CAST(:radius_m AS float) + ) + """), + { + "lon": centroid_lon, + "lat": centroid_lat, + "radius_m": _GEO_PRICE_RADIUS_M, + }, + ) + .mappings() + .first() + ) + _grp_n = int(grp_row["n"]) if grp_row and grp_row["n"] is not None else 0 + _grp_nc = ( + int(grp_row["n_complexes"]) + if grp_row and grp_row["n_complexes"] is not None + else 0 + ) + # Порог устойчивости медианы: n≥10 лотов И ≥2 ЖК (один ЖК не должен доминировать). + if ( + grp_row + and grp_row["median"] is not None + and _grp_n >= _GEO_PRICE_MIN_LOTS + and _grp_nc >= _GEO_PRICE_MIN_COMPLEXES + ): + geo_radius_price = { + "median": float(grp_row["median"]), + "lots_count": _grp_n, + "complexes_count": _grp_nc, + "radius_m": _GEO_PRICE_RADIUS_M, + "source": "objective_geo_radius", + } + else: + geo_radius_price = { + "median": None, + "lots_count": _grp_n, + "complexes_count": _grp_nc, + "radius_m": _GEO_PRICE_RADIUS_M, + "source": "no_data", + } + except Exception as e: + logger.warning("geo_radius_price query failed for %s: %s", cad_num, e) + geo_radius_price = { + "median": None, + "lots_count": 0, + "complexes_count": 0, + "radius_m": _GEO_PRICE_RADIUS_M, + "source": "no_data", + } + else: + geo_radius_price = { + "median": None, + "lots_count": 0, + "complexes_count": 0, + "radius_m": _GEO_PRICE_RADIUS_M, + "source": "no_data_centroid_fallback", + } + # 10d-pre2) #105 Phase 5: РНС/РВЭ в квартале (cad_num quarter prefix match). # Spatial filter через geom недоступен до Phase 3 (geocoding → geom). # Используем quarter prefix (первые 3 сегмента кад. номера) как прокси. @@ -3014,6 +3122,9 @@ def analyze_parcel( area_m2=_fin_area_m2, nspd_zoning=nspd_dump_data["nspd_zoning"], market_price=market_price, + # Гео-радиусная медиана новостроек — приоритет МЕЖДУ квартальной MV и + # district_reference: закрывает 5 не-name-match районов ЕКБ (≈line 2693). + geo_radius_price_median=geo_radius_price.get("median"), district_price_median=district_price_block["district_price_per_m2_median"], cadastral_value_rub=_fin_cad_value, gate_verdict=gate_verdict, @@ -3089,6 +3200,8 @@ def analyze_parcel( "market_trend": market_trend, # #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2 "market_price": market_price, + # Гео-радиусная медиана цены новостроек Objective вокруг центроида (transparency) + "geo_radius_price": geo_radius_price, # #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость) "parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None, # #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m) diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index d9765b42..14f268c2 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -541,8 +541,10 @@ def compute_financial( ``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``. + "objective_district_median" / "district_reference" / "class_norm" (concepts-путь + через ``_lookup_market_price``), либо "objective_geo_radius" — этот лейбл + приходит ТОЛЬКО из site-finder-моста (``synthesize_parcel_financial``), не из + concepts. Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``. development_type: "spot"/"mid_rise"/"high_rise" — задаёт длительность СМР в DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный → дефолт mid_rise (36 мес). График — типовое допущение (schedule_is_default). diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py index 239036de..79a052ec 100644 --- a/backend/app/services/site_finder/parcel_financial.py +++ b/backend/app/services/site_finder/parcel_financial.py @@ -166,12 +166,24 @@ def synthesize_parcel_financial( district_price_median: float | None, cadastral_value_rub: float | None, gate_verdict: dict[str, Any] | None, + geo_radius_price_median: float | None = None, velocity_sqm_per_month: float | None = None, ) -> dict[str, Any] | None: """Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``. Gate-guard: считаем ТОЛЬКО когда gate допускает МКД и есть числа зоны + площадь. + Цена продажи калибруется по приоритету (от точного рыночного к нормативу): + + 1. ``market_price.median`` — квартальная MV перепродаж (resale) → + ``price_source="objective_district_median"``. + 2. ``geo_radius_price_median`` — медиана новостроек Objective в гео-радиусе вокруг + центроида участка → ``price_source="objective_geo_radius"``. Закрывает пробел + district_reference (только 4 из 9 админ-районов ЕКБ матчатся по имени). + 3. ``district_price_median`` — name-match медиана админ-района → + ``price_source="district_reference"``. + 4. норматив класса → ``price_source="class_norm"``. + Args: area_m2: площадь участка, кв.м (из parcel_meta/EGRN или geometry_suitability). nspd_zoning: блок зоны НСПД/ПЗЗ — берём ``max_far`` / ``max_building_pct`` / @@ -180,6 +192,9 @@ def synthesize_parcel_financial( district_price_median: медиана цены по району, руб/кв.м (fallback к market). cadastral_value_rub: кадастровая стоимость участка → land_cost (НЕ рыночная). gate_verdict: can-build-MKD вердикт — гейт на расчёт. + geo_radius_price_median: медиана цены новостроек Objective (``price_per_m2_rub``) + в гео-радиусе вокруг центроида участка, руб/кв.м. Приоритет МЕЖДУ квартальной + MV и ``district_price_median``. ``None`` → слой пропускается (back-compat). velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового конкурента района (``velocity.project_absorption_sqm_per_month``), м²/мес. Прокидывается в :func:`compute_financial` как ``market_velocity_sqm_per_month`` → драйвит @@ -210,12 +225,17 @@ def synthesize_parcel_financial( if area_m2 is None or area_m2 <= 0: return None - # ── Цена продажи: market median → district median (рыночный прокси) ───────── + # ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─ + # Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV + # и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. price: float | None = None price_source = "class_norm" if market_price and _as_float(market_price.get("median")) is not None: price = _as_float(market_price.get("median")) price_source = "objective_district_median" + elif _as_float(geo_radius_price_median) is not None: + price = _as_float(geo_radius_price_median) + price_source = "objective_geo_radius" elif district_price_median is not None: price = float(district_price_median) price_source = "district_reference" diff --git a/backend/tests/services/site_finder/test_parcel_financial.py b/backend/tests/services/site_finder/test_parcel_financial.py index 347f8960..bcc85d60 100644 --- a/backend/tests/services/site_finder/test_parcel_financial.py +++ b/backend/tests/services/site_finder/test_parcel_financial.py @@ -320,6 +320,75 @@ def test_financial_price_source_class_norm_when_no_market() -> None: assert fin["housing_class_inferred"] == "comfort" +# ── geo_radius_price: приоритет МЕЖДУ quarter-MV и district_reference ────────── + + +def test_financial_quarter_mv_wins_over_geo_radius() -> None: + """Есть quarter-MV median → geo-радиус игнорируется (объективный приоритет MV).""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"median": 150_000.0}, + geo_radius_price_median=200_000.0, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "objective_district_median" + assert fin["price_per_sqm_used"] == 150_000.0 + + +def test_financial_price_source_geo_radius_when_no_quarter_mv() -> None: + """Нет quarter-MV, но есть geo-радиус → source=objective_geo_radius, цена из него.""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"deals_count": 0, "source": "no_data"}, + geo_radius_price_median=185_000.0, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "objective_geo_radius" + assert fin["price_is_calibrated"] is True + assert fin["price_per_sqm_used"] == 185_000.0 + assert fin["housing_class_inferred"] == "business" # 185k → business (>=177k) + + +def test_financial_district_fallback_when_no_quarter_mv_no_geo_radius() -> None: + """Нет quarter-MV и geo-радиуса, но есть district → district_reference (без регресса).""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"deals_count": 0, "source": "no_data"}, + geo_radius_price_median=None, + district_price_median=130_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "district_reference" + assert fin["price_per_sqm_used"] == 130_000.0 + + +def test_financial_class_norm_when_all_price_sources_absent() -> None: + """Все источники цены отсутствуют (incl. geo-радиус) → class_norm.""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price=None, + geo_radius_price_median=None, + district_price_median=None, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "class_norm" + assert fin["price_is_calibrated"] is False + + # ── интеграция: реалистичный участок ────────────────────────────────────────── diff --git a/data/sql/168_complexes_makepoint_geog_idx.sql b/data/sql/168_complexes_makepoint_geog_idx.sql new file mode 100644 index 00000000..7f8f0687 --- /dev/null +++ b/data/sql/168_complexes_makepoint_geog_idx.sql @@ -0,0 +1,60 @@ +-- 168_complexes_makepoint_geog_idx.sql +-- Функциональный geography-GIST на complexes для гео-радиусной калибровки цены в /analyze. +-- Ветка: feat/geo-radius-price-calibration. +-- +-- КОРНЕВАЯ ПРИЧИНА: новый блок «гео-радиусная цена» (parcels.py:2705, секция 10d-pre1b) +-- считает медиану price_per_m2_rub новостроек Objective в 3км вокруг центроида участка. +-- У objective_lots нет geom → коорды берутся через complexes (latitude/longitude), +-- join по ol.complex_id = c.id. Предикат — точный метровый ST_DWithin по geography: +-- +-- ST_DWithin( +-- ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, +-- ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, +-- 3000) +-- +-- Существующий complexes_geom_idx — ПЛОСКИЙ geom-GIST (geometry, SRID 4326). Планировщик +-- НЕ может им воспользоваться для geography-выражения над longitude/latitude → Seq Scan +-- на complexes на КАЖДЫЙ /analyze (prod EXPLAIN ANALYZE подтвердил). Это тот же урок, +-- что в 157 (#157): для geography-предиката нужен функциональный GIST на ИМЕННО том +-- выражении, которое стоит в WHERE. Здесь выражение — не `geom::geography`, а +-- `ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography`, поэтому индекс +-- строится на нём байт-в-байт (планировщик матчит индекс по точному выражению). +-- +-- ИЗМЕРЕНИЯ (prod, EKB-центр lon=60.605 lat=56.838, radius 3000м; «после» — индекс +-- создан в BEGIN…ROLLBACK для проверки плана): +-- ДО: Seq Scan on complexes, ~111 ms +-- ПОСЛЕ: Index Scan на (ST_SetSRID(ST_MakePoint(longitude,latitude),4326)::geography), +-- ~53 ms (≈2x). Снимает Seq-Scan-нагрузку с /analyze (timeout-давление, #1850). +-- +-- Плоский complexes_geom_idx НЕ трогаем — он остаётся для geometry-веток (ST_Intersects +-- и т.п.). Этот индекс — дополнительный, под конкретный geography-предикат. +-- +-- WHERE-условие частичного индекса (latitude/longitude IS NOT NULL) повторяет фильтр +-- запроса (c.latitude/c.longitude IS NOT NULL) → индекс не раздувается строками без +-- координат и планировщик его подбирает под тот же предикат. +-- +-- LOCK-ПРОФИЛЬ: обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE на complexes — +-- блокирует только writes на миллисекунды. complexes крошечная (~2023 строки), build +-- мгновенный. CONCURRENTLY здесь НЕВОЗМОЖЕН: deploy.yml гоняет .sql в транзакции +-- (BEGIN/COMMIT), а CREATE INDEX CONCURRENTLY в tx запрещён. Тот же класс риска, что 157. +-- +-- Dependencies: complexes (latitude, longitude, id), objective_lots (complex_id) — обе +-- существуют. PostgreSQL 16 + PostGIS 3.4. +-- Idempotent: CREATE INDEX IF NOT EXISTS (re-apply безопасен). +-- Applied: автоматически через deploy.yml в NN-порядке (трекер _schema_migrations). +-- Backend code change НЕ требуется — индекс прозрачен для существующего запроса. + +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_complexes_makepoint_geog + ON complexes USING gist ((ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography)) + WHERE latitude IS NOT NULL AND longitude IS NOT NULL; + +COMMENT ON INDEX idx_complexes_makepoint_geog IS + 'Geography GIST для гео-радиусной калибровки цены в /analyze (#168, ветка ' + 'feat/geo-radius-price-calibration): ST_DWithin(' + 'ST_SetSRID(ST_MakePoint(longitude,latitude),4326)::geography, …, 3000м). ' + 'Плоский complexes_geom_idx не обслуживает это geography-выражение → был Seq Scan ' + '~111мс, стал Index Scan ~53мс. Тот же урок, что 157. См. parcels.py:2705.'; + +COMMIT; diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index faa57fd2..ba8d16df 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -131,6 +131,23 @@ const _DEVELOPMENT_TYPE_RU: Record = { high_rise: "высотная", }; +// Honest RU caption per backend price_source token. Backend отдаёт латиницей; +// в UI финдиректор должен видеть, откуда взята цена, человеческим языком — не +// сырой токен «objective_geo_radius». Неизвестный/пустой → "" (suffix опускаем, +// сырой токен не печатаем). См. .claude/rules/ui-microcopy.md. +const _PRICE_SOURCE_RU: Record = { + objective_district_median: "медиана сделок квартала", + objective_geo_radius: "медиана рынка в радиусе 3 км", + district_reference: "ориентир по району", + class_norm: "норматив класса", +}; + +/** Источник цены → честная RU-подпись в «ёлочках»; неизвестный/пустой → "". */ +function priceSourceCaption(source: string): string { + const ru = _PRICE_SOURCE_RU[source]; + return ru ? `«${ru}»` : ""; +} + // ── НСПД-регламент (ПЗЗ) helpers ────────────────────────────────────────────── /** КСИТ (max_far) as a plain RU number — e.g. "3,5". */ @@ -1742,11 +1759,10 @@ export function adaptFinanceDrawer( } const teap = financial.teap_synth; + const priceSourceSuffix = priceSourceCaption(financial.price_source); const priceLabel = financial.price_is_calibrated ? "рынок (Objective)" - : `норматив класса${ - financial.price_source ? ` · ${financial.price_source}` : "" - }`; + : `норматив класса${priceSourceSuffix ? ` · ${priceSourceSuffix}` : ""}`; const irrLabel = financial.irr_is_proxy ? `${formatPctFromFraction(financial.irr)} % · оценочный (proxy)` : `${formatPctFromFraction(financial.irr)} % · DCF`; @@ -2123,9 +2139,12 @@ export function adaptInvestmentClearance( }; } + const priceSourceSuffix = priceSourceCaption(financial.price_source); const priceCaption = financial.price_is_calibrated ? "цена: рынок (Objective)" - : "цена: норматив класса"; + : `цена: норматив класса${ + priceSourceSuffix ? ` · ${priceSourceSuffix}` : "" + }`; const irrCaption = financial.irr_is_proxy ? "оценочный (proxy)" : "DCF"; const paybackValue = financial.payback_months != null