diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 058cc5ce..6600a3ae 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2420,7 +2420,8 @@ def analyze_parcel( COUNT(*) FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months' AND NOW() - INTERVAL '6 months') - AS prior_n + AS prior_n, + MAX(deal_date) AS max_period FROM district_deals """), {"wkt": geom_wkt}, @@ -2428,7 +2429,26 @@ def analyze_parcel( .mappings() .first() ) + # SF#1871 P2: честность staleness. Источник сделок (rosreestr) может встать — + # тогда recent-окно (0-6 мес) пусто → recent_avg=NULL. Раньше market_trend + # обнулялся в голый None, и фронт/скоринг не могли отличить «нет данных рядом» + # от «источник устарел». Теперь явный status. (ETL-фикс — вне scope.) + _raw_max_period = trend_row["max_period"] if trend_row else None + # period_start_date — DATE; нормализуем на случай timestamp (.date()), чтобы + # сравнение/форматирование не падало на datetime vs date. + max_period: _dt.date | None + if isinstance(_raw_max_period, _dt.datetime): + max_period = _raw_max_period.date() + elif isinstance(_raw_max_period, _dt.date): + max_period = _raw_max_period + else: + max_period = None + recent_n_val = int(trend_row["recent_n"]) if trend_row and trend_row["recent_n"] else 0 + # Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня. + stale_cutoff = _dt.date.today() - _dt.timedelta(days=90) + if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]: + # Happy path — есть данные в обоих окнах. recent_p = float(trend_row["recent_avg"]) prior_p = float(trend_row["prior_avg"]) # 6-месячное изменение; ×2 даёт годовой эквивалент @@ -2442,6 +2462,7 @@ def analyze_parcel( else: perspective_label = "Падение — риск переоценки" market_trend = { + "status": "ok", "recent_avg_price_per_m2": round(recent_p), "prior_avg_price_per_m2": round(prior_p), "delta_6m_pct": delta_6m_pct, @@ -2450,6 +2471,25 @@ def analyze_parcel( "label": perspective_label, "radius_km": 3, } + elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff: + # Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»). + market_trend = { + "status": "source_stale", + "as_of_date": max_period.isoformat(), + "label": ( + "Источник сделок устарел " + f"(данные до {max_period.strftime('%m.%Y')})" + ), + "recent_deals_count": 0, + } + else: + # Источник свежий (или вообще нет данных в радиусе), но по объекту 0 сделок + # в recent-окне → честно «нет сделок», а не подмена под устаревание. + market_trend = { + "status": "no_deals", + "label": "Нет сделок в радиусе за период", + "recent_deals_count": 0, + } except Exception as e: logger.warning("market_trend query failed for %s: %s", cad_num, e) market_trend = None diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index 75b68784..44621e3f 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -36,9 +36,12 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) -# Fallback если в БД нет данных за окно months_window. -# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025). -_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0 +# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median). +# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на +# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка: +# чем больше знаменатель нормализации, тем ниже velocity_score). Прежнее 4500.0 было +# завышено ~7.6× и латентно занижало score при срабатывании fallback. +_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 750.0 # Порог: если доля конкурентов с Objective-маппингом < этого значения, # пытаемся rosreestr_fallback. @@ -83,6 +86,15 @@ class VelocityResult: # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу), # none (нет данных). velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective" + # SF#1871 P2: честность покрытия / proxy-disclosure (additive — фронт/PDF не ломаются). + # objective_coverage_pct — доля конкурентов с Objective-маппингом И данными (mapped_ratio×100). + # 0.0 на early/none-путях, где покрытие неизвестно. + objective_coverage_pct: float = 0.0 + # proxy_used — True когда объём фабриковался через count×45 (rosreestr_fallback путь), + # а не брался из реального deals_total_vol_m2 Objective. + proxy_used: bool = False + # proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none. + proxy_sqm_per_deal: float | None = None def as_dict(self) -> dict[str, Any]: return { @@ -97,6 +109,9 @@ class VelocityResult: "by_room_bucket": self.by_room_bucket, "velocity_data_available": self.velocity_data_available, "velocity_source": self.velocity_source, + "objective_coverage_pct": self.objective_coverage_pct, + "proxy_used": self.proxy_used, + "proxy_sqm_per_deal": self.proxy_sqm_per_deal, } @@ -271,6 +286,8 @@ def compute_velocity( r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0 ] mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0 + # SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными). + coverage_pct = round(mapped_ratio * 100, 1) ekb_median = ( _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH @@ -306,6 +323,7 @@ def compute_velocity( n_comps=n_comps, ekb_median=ekb_median, sample_competitors=sample_no_data, + objective_coverage_pct=coverage_pct, ) if rr_result is not None: return rr_result @@ -324,6 +342,9 @@ def compute_velocity( by_room_bucket={}, velocity_data_available=False, velocity_source="none", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) # ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── @@ -429,6 +450,7 @@ def compute_velocity( n_comps=n_comps, ekb_median=ekb_median, sample_competitors=sample_no_data, + objective_coverage_pct=coverage_pct, ) if rr_result is not None: return rr_result @@ -459,6 +481,9 @@ def compute_velocity( by_room_bucket={}, velocity_data_available=False, velocity_source="none", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). @@ -522,6 +547,9 @@ def compute_velocity( by_room_bucket=by_room_bucket, velocity_data_available=True, velocity_source="objective", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) @@ -532,6 +560,7 @@ def _compute_rosreestr_fallback( n_comps: int, ekb_median: float, sample_competitors: list[dict[str, Any]], + objective_coverage_pct: float = 0.0, ) -> VelocityResult | None: """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. @@ -618,6 +647,9 @@ def _compute_rosreestr_fallback( by_room_bucket={}, # rosreestr не даёт room_bucket velocity_data_available=True, velocity_source="rosreestr_fallback", + objective_coverage_pct=objective_coverage_pct, + proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется) + proxy_sqm_per_deal=avg_area_per_deal, ) diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index c5aff67b..c9334771 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -18,6 +18,7 @@ import pytest from app.services.site_finder.velocity import ( _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, VelocityResult, + _compute_rosreestr_fallback, compute_velocity, ) @@ -243,6 +244,9 @@ def test_as_dict_structure(): by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}}, velocity_data_available=True, velocity_source="objective", + objective_coverage_pct=66.7, + proxy_used=False, + proxy_sqm_per_deal=None, ) d = vr.as_dict() assert "competitors_count" in d @@ -254,9 +258,13 @@ def test_as_dict_structure(): assert d["velocity_score"] == pytest.approx(0.333, abs=1e-3) assert "by_room_bucket" in d assert d["by_room_bucket"]["1"]["units"] == 10 - # OBJ-2: новые поля velocity_data_available и velocity_source + # OBJ-2: поля velocity_data_available и velocity_source assert d["velocity_data_available"] is True assert d["velocity_source"] == "objective" + # SF#1871 P2: coverage% + proxy disclosure + assert d["objective_coverage_pct"] == pytest.approx(66.7) + assert d["proxy_used"] is False + assert d["proxy_sqm_per_deal"] is None def test_sample_competitors_top5(): @@ -360,6 +368,73 @@ def test_mapping_confidence_gate_in_sales_query(): assert "cm.match_score >= 0.85" in sql +def test_fallback_constant_is_conservative(): + """SF#1871 P2 Fix1: fallback-медиана понижена 4500→750 (audit-verified 593-766).""" + assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0) + + +def test_objective_path_surfaces_coverage_no_proxy(): + """SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None.""" + n = 6 + comp_rows = [_comp_row(i) for i in range(1, n + 1)] + sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) + + assert result is not None + assert result.velocity_source == "objective" + assert result.proxy_used is False + assert result.proxy_sqm_per_deal is None + # все 6 конкурентов mapped + с данными → coverage 100% + assert result.objective_coverage_pct == pytest.approx(100.0) + d = result.as_dict() + assert d["objective_coverage_pct"] == pytest.approx(100.0) + assert d["proxy_used"] is False + assert d["proxy_sqm_per_deal"] is None + + +def test_rosreestr_fallback_marks_proxy_disclosure(): + """SF#1871 P2 Fix2: rosreestr_fallback → proxy_used=True, proxy_sqm_per_deal=45.0, + coverage% пробрасывается из вызывающего кода.""" + row = MagicMock() + start_d = datetime.date.fromisoformat("2025-01-01") + end_d = datetime.date.fromisoformat("2025-06-01") + row.__getitem__ = lambda self, k: { + "total_deals": 60, + "period_start": start_d, + "period_end": end_d, + }[k] + result_mock = MagicMock() + result_mock.mappings.return_value.first.return_value = row + db = MagicMock() + db.execute.return_value = result_mock + + result = _compute_rosreestr_fallback( + db=db, + cad_quarter="66:41:0702048", + months_window=6, + n_comps=8, + ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + sample_competitors=[], + objective_coverage_pct=25.0, + ) + + assert result is not None + assert result.velocity_source == "rosreestr_fallback" + assert result.proxy_used is True + assert result.proxy_sqm_per_deal == pytest.approx(45.0) + assert result.objective_coverage_pct == pytest.approx(25.0) + d = result.as_dict() + assert d["proxy_used"] is True + assert d["proxy_sqm_per_deal"] == pytest.approx(45.0) + assert d["objective_coverage_pct"] == pytest.approx(25.0) + + def test_sample_competitors_include_by_room_bucket(): """sample_competitors каждого элемента содержит by_room_bucket.""" comp_rows = [_comp_row(1), _comp_row(2)] diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index 9519f1c9..dc2c7e57 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -268,7 +268,7 @@ function SiteFinderContent() { trendPct != null ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` : "—"; - const trendLabel = data?.market_trend + const trendLabel = data?.market_trend?.label ? `Тренд за 6 мес · ${data.market_trend.label}` : "Тренд рынка"; const trendKpiColor: KpiColor = diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx index c4af54c0..733d0718 100644 --- a/frontend/src/components/site-finder/MarketTrendBlock.tsx +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import type { MarketTrend } from "@/types/site-finder"; +import type { MarketTrend, MarketTrendStatus } from "@/types/site-finder"; interface Props { trend?: MarketTrend | null; @@ -32,59 +32,127 @@ function fmtPrice(v: number): string { return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); } +/** + * P2 (#1871): дата as_of_date (YYYY-MM-DD) → «месяц год» для caption о + * устаревшем источнике. Возвращает null если дата отсутствует/некорректна. + */ +const RU_MONTHS = [ + "январь", + "февраль", + "март", + "апрель", + "май", + "июнь", + "июль", + "август", + "сентябрь", + "октябрь", + "ноябрь", + "декабрь", +]; + +function formatAsOfMonth(asOf: string | undefined): string | null { + if (!asOf) return null; + // Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы + // месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»). + const m = /^(\d{4})-(\d{2})/.exec(asOf); + if (!m) return null; + const year = Number(m[1]); + const monthIdx = Number(m[2]) - 1; + if (monthIdx < 0 || monthIdx > 11) return null; + return `${RU_MONTHS[monthIdx]} ${year}`; +} + +/** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */ +function TrendCaveat({ title, message }: { title: string; message: string }) { + return ( +
+
+ {title} +
+
{message}
+
+ ); +} + export function MarketTrendBlock({ trend }: Props) { if (!trend) { return ( -
-
- Тренд рынка -
-
- Недостаточно сделок ДДУ в радиусе для тренда -
-
+ ); } + // P2 (#1871): отсутствие status трактуем как "ok" (старый/кэшированный ответ). + const status: MarketTrendStatus = trend.status ?? "ok"; + const radiusSuffix = + trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : ""; + const title = `Тренд рынка${radiusSuffix}`; + + // P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда. + if (status === "source_stale") { + const asOfMonth = formatAsOfMonth(trend.as_of_date); + const message = + trend.label ?? + (asOfMonth != null + ? `Источник сделок устарел (данные до ${asOfMonth}).` + : "Источник сделок устарел — тренд не показываем."); + return ; + } + + // P2 (#1871): нет сделок в радиусе за период. + if (status === "no_deals") { + return ( + + ); + } + + // status === "ok" — старый контракт с полными числовыми полями. const n = trend.recent_deals_count; + // Без числовых полей (defensive) — показываем no-data, не падаем. + if ( + trend.delta_6m_pct == null || + trend.recent_avg_price_per_m2 == null || + trend.prior_avg_price_per_m2 == null || + trend.prior_deals_count == null + ) { + return ( + + ); + } + // n < 30 — hide trend entirely, show data-insufficient state if (n < N_WEAK) { return ( -
-
- Тренд рынка в радиусе {trend.radius_km} км -
-
- Недостаточно данных (n={n}) — расширьте радиус для анализа тренда -
-
+ ); } - const arrow = trendArrow(trend.delta_6m_pct); - const arrowColor = deltaColor(trend.delta_6m_pct); + const delta = trend.delta_6m_pct; + const arrow = trendArrow(delta); + const arrowColor = deltaColor(delta); // Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее // инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу. - const labelKey = trend.label.split(" — ")[0]; + const label = trend.label ?? ""; + const labelKey = label.split(" — ")[0]; const labelStyle = LABEL_COLORS[labelKey] ?? { bg: "#f3f4f6", color: "#374151", @@ -103,7 +171,7 @@ export function MarketTrendBlock({ trend }: Props) { }} >
- Тренд рынка в радиусе {trend.radius_km} км + {title}
{/* Main price */} @@ -136,8 +204,8 @@ export function MarketTrendBlock({ trend }: Props) { > {arrow} - {trend.delta_6m_pct > 0 ? "+" : ""} - {trend.delta_6m_pct.toFixed(1)}% + {delta > 0 ? "+" : ""} + {delta.toFixed(1)}% @@ -148,21 +216,23 @@ export function MarketTrendBlock({ trend }: Props) { {/* Label badge */} -
- {trend.label} -
+ {label && ( +
+ {label} +
+ )} {/* Weak data warning: 30 ≤ n < 100 */} {n < N_STRONG && ( diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index 063b891d..de2a19d4 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -38,6 +38,32 @@ function formatPercent(score: number): string { return `${Math.round(score * 100)}%`; } +/** + * P2 (#1871): caveat о покрытии/прокси объёма velocity. + * - proxy_used === true → полное предложение про прокси-оценку и покрытие. + * - proxy_used !== true → только точность покрытия (если backend её дал). + * Возвращает null когда показывать нечего. + */ +function coverageCaption(velocity: Velocity): string | null { + if (velocity.proxy_used === true) { + const parts: string[] = []; + if (velocity.proxy_sqm_per_deal != null) { + parts.push( + `прокси ${velocity.proxy_sqm_per_deal.toLocaleString("ru")} м²/сделка`, + ); + } + if (velocity.objective_coverage_pct != null) { + parts.push(`покрытие ${Math.round(velocity.objective_coverage_pct)}%`); + } + const detail = parts.length > 0 ? ` (${parts.join(", ")})` : ""; + return `Объём оценочный${detail}.`; + } + if (velocity.objective_coverage_pct != null) { + return `Покрытие данными Objective: ${Math.round(velocity.objective_coverage_pct)}%.`; + } + return null; +} + /** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */ function paceBand(score: number): { label: string; color: string } { if (score >= 0.66) @@ -64,6 +90,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { const scorePct = formatPercent(velocity.velocity_score); const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; const band = paceBand(velocity.velocity_score); + const coverageNote = coverageCaption(velocity); // Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/ // медленнее рынка» по соотношению, отдельно от шкалы 0–100%). const aboveMarket = ratio >= 1; @@ -197,6 +224,21 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { {Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес (медиана ЕКБ) + + {/* P2 (#1871) — caveat покрытия/прокси приглушённым caption-стилем */} + {coverageNote && ( +
+ {coverageNote} +
+ )} )} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index fbb09010..151341ef 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -367,7 +367,13 @@ function buildVerdict(data: ParcelAnalysis): string | null { } } - if (trend) { + // P2 (#1871): дельту цен добавляем только когда тренд посчитан (status "ok" + // или старый ответ без status); при source_stale/no_deals — пропускаем. + if ( + trend && + (trend.status == null || trend.status === "ok") && + trend.delta_6m_pct != null + ) { const d = trend.delta_6m_pct; const sign = d > 0 ? "+" : d < 0 ? "−" : ""; const abs = Math.abs(d).toLocaleString("ru", { diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index d6101138..728f05c7 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -1163,17 +1163,32 @@ export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer { : notReal("нет данных района"); const mt = a.market_trend; + // P2 (#1871): тренд «настоящий» только при status "ok" (или старом ответе без + // status, где числовые поля присутствуют). source_stale/no_deals → честный + // placeholder с причиной вместо фейкового процента. + const mtStatus = mt?.status; + const mtComputed = + mt != null && + (mtStatus == null || mtStatus === "ok") && + mt.delta_6m_pct != null && + mt.recent_avg_price_per_m2 != null && + mt.prior_avg_price_per_m2 != null; + const trendCaption = + mtStatus === "source_stale" + ? "источник сделок устарел" + : mtStatus === "no_deals" + ? "нет сделок за период" + : "после прогноза"; const trend = { - field: - mt != null - ? real( - `${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`, - ) - : notReal("после прогноза"), - recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH, - prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH, + field: mtComputed + ? real( + `${mt!.delta_6m_pct! >= 0 ? "+" : ""}${fmtNum(mt!.delta_6m_pct!, 1)}%`, + ) + : notReal(trendCaption), + recent: mtComputed ? formatRubPerM2(mt!.recent_avg_price_per_m2!) : DASH, + prior: mtComputed ? formatRubPerM2(mt!.prior_avg_price_per_m2!) : DASH, count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH, - available: mt != null, + available: mtComputed, }; const v = a.velocity ?? null; diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index d792a391..5e3e5598 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -175,14 +175,25 @@ export interface NeighborsSummary { note?: string; } +// P2 (#1871) — staleness/coverage статус источника сделок. +// "ok" — тренд посчитан; "source_stale" — источник сделок устарел (тренд не +// рисуем); "no_deals" — нет сделок в радиусе за период. +export type MarketTrendStatus = "ok" | "source_stale" | "no_deals"; + export interface MarketTrend { - recent_avg_price_per_m2: number; - prior_avg_price_per_m2: number; - delta_6m_pct: number; + // P2 (#1871): обязателен в новых ответах; optional для backward-compat со + // старыми/кэшированными ответами без поля (трактуем отсутствие как "ok"). + status?: MarketTrendStatus; + // YYYY-MM-DD — дата последней доступной сделки источника (для caption staleness). + as_of_date?: string; recent_deals_count: number; - prior_deals_count: number; - label: string; - radius_km: number; + // Поля ниже присутствуют только при status === "ok" (старый контракт). + recent_avg_price_per_m2?: number; + prior_avg_price_per_m2?: number; + delta_6m_pct?: number; + prior_deals_count?: number; + label?: string; + radius_km?: number; } // Market pulse — district absorption + competitor counts (/analyze market layer). @@ -295,6 +306,13 @@ export interface Velocity { // 'objective' — Objective (основной), 'rosreestr_fallback' — кадастровый квартал, // 'none' — нет данных. velocity_source?: "objective" | "rosreestr_fallback" | "none"; + // P2 (#1871) — покрытие/прокси (optional для backward-compat). + // objective_coverage_pct — доля объёма из реальных данных Objective (0..100). + objective_coverage_pct?: number; + // proxy_used — объём оценён по прокси (м²/сделка) вместо прямого замера. + proxy_used?: boolean; + // proxy_sqm_per_deal — коэффициент прокси (м² на сделку), null если прокси не применён. + proxy_sqm_per_deal?: number | null; } // G5 (#32) — Gate verdict: can_build_mkd