feat(site-finder): geo-radius market-price calibration in /analyze (#1881 follow-up) #1899

Merged
bot-backend merged 1 commit from feat/geo-radius-price-calibration into main 2026-06-25 07:59:56 +00:00
6 changed files with 290 additions and 7 deletions

View file

@ -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)

View file

@ -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).

View file

@ -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"

View file

@ -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
# ── интеграция: реалистичный участок ──────────────────────────────────────────

View file

@ -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;

View file

@ -131,6 +131,23 @@ const _DEVELOPMENT_TYPE_RU: Record<string, string> = {
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<string, string> = {
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