fix(site-finder): velocity fallback 4500→750, coverage/proxy disclosure, market_trend staleness honesty (audit #1871 P2)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 10m9s

Три honesty/correctness-фикса из audit-issue #1871 (🟠 P2). Все факты
подтверждены prod-DB + разведкой. ETL не трогаем (отдельная задача).

velocity.py:
- _EKB_MEDIAN_FALLBACK 4500→750 (audit-verified медиана ЕКБ 593-766;
  4500 завышало знаменатель нормализации 7.6× → занижало velocity_score
  при DB-error fallback, latent). Консервативно 750 (верх диапазона).
- VelocityResult + as_dict() раскрывают: objective_coverage_pct,
  proxy_used, proxy_sqm_per_deal — теперь видно что объём rosreestr-пути
  фабрикуется count×45 м²/сделка при низком покрытии Objective (<50%).
  Проброшено во все 4 конструктора, additive.

parcels.py (market_trend producer):
- Различать «источник устарел» от «нет сделок». rosreestr_deals
  MAX(period_start_date)=2026-01-01 (stale 5.5 мес) → recent-окно пусто →
  раньше голый null. Теперь status ∈ {ok, source_stale, no_deals} +
  as_of_date + label + recent_deals_count. MAX(deal_date) добавлен в CTE.
  Consumer scoring читает recent_deals_count защищённо (без регрессии).

frontend (types + MarketTrendBlock + VelocityBlock + ptica market-drawer
+ Section3 verdict + legacy guard):
- staleness/no_deals → честный caveat вместо фейкового тренда.
- velocity → caption «объём оценочный (прокси N м²/сделка, покрытие P%)».
- formatAsOfMonth парсит YYYY-MM напрямую (TZ-safe).
- Типы MarketTrend/Velocity расширены (optional, backward-compat;
  отсутствие status = "ok").

Тесты: +3 velocity unit + extended as_dict contract. 17 velocity passed,
138 frontend vitest passed.

Refs #1871
This commit is contained in:
Light1YT 2026-06-23 12:04:08 +05:00
parent 85d91375dc
commit adf3ec7c00
9 changed files with 376 additions and 78 deletions

View file

@ -2420,7 +2420,8 @@ def analyze_parcel(
COUNT(*) COUNT(*)
FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months' FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
AND NOW() - INTERVAL '6 months') AND NOW() - INTERVAL '6 months')
AS prior_n AS prior_n,
MAX(deal_date) AS max_period
FROM district_deals FROM district_deals
"""), """),
{"wkt": geom_wkt}, {"wkt": geom_wkt},
@ -2428,7 +2429,26 @@ def analyze_parcel(
.mappings() .mappings()
.first() .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"]: if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
# Happy path — есть данные в обоих окнах.
recent_p = float(trend_row["recent_avg"]) recent_p = float(trend_row["recent_avg"])
prior_p = float(trend_row["prior_avg"]) prior_p = float(trend_row["prior_avg"])
# 6-месячное изменение; ×2 даёт годовой эквивалент # 6-месячное изменение; ×2 даёт годовой эквивалент
@ -2442,6 +2462,7 @@ def analyze_parcel(
else: else:
perspective_label = "Падение — риск переоценки" perspective_label = "Падение — риск переоценки"
market_trend = { market_trend = {
"status": "ok",
"recent_avg_price_per_m2": round(recent_p), "recent_avg_price_per_m2": round(recent_p),
"prior_avg_price_per_m2": round(prior_p), "prior_avg_price_per_m2": round(prior_p),
"delta_6m_pct": delta_6m_pct, "delta_6m_pct": delta_6m_pct,
@ -2450,6 +2471,25 @@ def analyze_parcel(
"label": perspective_label, "label": perspective_label,
"radius_km": 3, "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: except Exception as e:
logger.warning("market_trend query failed for %s: %s", cad_num, e) logger.warning("market_trend query failed for %s: %s", cad_num, e)
market_trend = None market_trend = None

View file

@ -36,9 +36,12 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Fallback если в БД нет данных за окно months_window. # Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025). # Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0 # один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
# чем больше знаменатель нормализации, тем ниже velocity_score). Прежнее 4500.0 было
# завышено ~7.6× и латентно занижало score при срабатывании fallback.
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 750.0
# Порог: если доля конкурентов с Objective-маппингом < этого значения, # Порог: если доля конкурентов с Objective-маппингом < этого значения,
# пытаемся rosreestr_fallback. # пытаемся rosreestr_fallback.
@ -83,6 +86,15 @@ class VelocityResult:
# Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу), # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу),
# none (нет данных). # none (нет данных).
velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective" 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]: def as_dict(self) -> dict[str, Any]:
return { return {
@ -97,6 +109,9 @@ class VelocityResult:
"by_room_bucket": self.by_room_bucket, "by_room_bucket": self.by_room_bucket,
"velocity_data_available": self.velocity_data_available, "velocity_data_available": self.velocity_data_available,
"velocity_source": self.velocity_source, "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 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 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 = ( ekb_median = (
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH _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, n_comps=n_comps,
ekb_median=ekb_median, ekb_median=ekb_median,
sample_competitors=sample_no_data, sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
) )
if rr_result is not None: if rr_result is not None:
return rr_result return rr_result
@ -324,6 +342,9 @@ def compute_velocity(
by_room_bucket={}, by_room_bucket={},
velocity_data_available=False, velocity_data_available=False,
velocity_source="none", velocity_source="none",
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
) )
# ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── # ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -429,6 +450,7 @@ def compute_velocity(
n_comps=n_comps, n_comps=n_comps,
ekb_median=ekb_median, ekb_median=ekb_median,
sample_competitors=sample_no_data, sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct,
) )
if rr_result is not None: if rr_result is not None:
return rr_result return rr_result
@ -459,6 +481,9 @@ def compute_velocity(
by_room_bucket={}, by_room_bucket={},
velocity_data_available=False, velocity_data_available=False,
velocity_source="none", velocity_source="none",
objective_coverage_pct=coverage_pct,
proxy_used=False,
proxy_sqm_per_deal=None,
) )
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
@ -522,6 +547,9 @@ def compute_velocity(
by_room_bucket=by_room_bucket, by_room_bucket=by_room_bucket,
velocity_data_available=True, velocity_data_available=True,
velocity_source="objective", 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, n_comps: int,
ekb_median: float, ekb_median: float,
sample_competitors: list[dict[str, Any]], sample_competitors: list[dict[str, Any]],
objective_coverage_pct: float = 0.0,
) -> VelocityResult | None: ) -> VelocityResult | None:
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
@ -618,6 +647,9 @@ def _compute_rosreestr_fallback(
by_room_bucket={}, # rosreestr не даёт room_bucket by_room_bucket={}, # rosreestr не даёт room_bucket
velocity_data_available=True, velocity_data_available=True,
velocity_source="rosreestr_fallback", 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,
) )

View file

@ -18,6 +18,7 @@ import pytest
from app.services.site_finder.velocity import ( from app.services.site_finder.velocity import (
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH,
VelocityResult, VelocityResult,
_compute_rosreestr_fallback,
compute_velocity, compute_velocity,
) )
@ -243,6 +244,9 @@ def test_as_dict_structure():
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}}, by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
velocity_data_available=True, velocity_data_available=True,
velocity_source="objective", velocity_source="objective",
objective_coverage_pct=66.7,
proxy_used=False,
proxy_sqm_per_deal=None,
) )
d = vr.as_dict() d = vr.as_dict()
assert "competitors_count" in d 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 d["velocity_score"] == pytest.approx(0.333, abs=1e-3)
assert "by_room_bucket" in d assert "by_room_bucket" in d
assert d["by_room_bucket"]["1"]["units"] == 10 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_data_available"] is True
assert d["velocity_source"] == "objective" 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(): 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 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(): def test_sample_competitors_include_by_room_bucket():
"""sample_competitors каждого элемента содержит by_room_bucket.""" """sample_competitors каждого элемента содержит by_room_bucket."""
comp_rows = [_comp_row(1), _comp_row(2)] comp_rows = [_comp_row(1), _comp_row(2)]

View file

@ -268,7 +268,7 @@ function SiteFinderContent() {
trendPct != null trendPct != null
? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%`
: "—"; : "—";
const trendLabel = data?.market_trend const trendLabel = data?.market_trend?.label
? `Тренд за 6 мес · ${data.market_trend.label}` ? `Тренд за 6 мес · ${data.market_trend.label}`
: "Тренд рынка"; : "Тренд рынка";
const trendKpiColor: KpiColor = const trendKpiColor: KpiColor =

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import type { MarketTrend } from "@/types/site-finder"; import type { MarketTrend, MarketTrendStatus } from "@/types/site-finder";
interface Props { interface Props {
trend?: MarketTrend | null; trend?: MarketTrend | null;
@ -32,59 +32,127 @@ function fmtPrice(v: number): string {
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); 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 (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
{title}
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>{message}</div>
</div>
);
}
export function MarketTrendBlock({ trend }: Props) { export function MarketTrendBlock({ trend }: Props) {
if (!trend) { if (!trend) {
return ( return (
<div <TrendCaveat
style={{ title="Тренд рынка"
borderRadius: 10, message="Недостаточно сделок ДДУ в радиусе для тренда"
padding: "14px 16px", />
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Тренд рынка
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Недостаточно сделок ДДУ в радиусе для тренда
</div>
</div>
); );
} }
// 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 <TrendCaveat title={title} message={message} />;
}
// P2 (#1871): нет сделок в радиусе за период.
if (status === "no_deals") {
return (
<TrendCaveat title={title} message="Нет сделок в радиусе за период." />
);
}
// status === "ok" — старый контракт с полными числовыми полями.
const n = trend.recent_deals_count; 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 (
<TrendCaveat
title={title}
message="Недостаточно сделок ДДУ в радиусе для тренда"
/>
);
}
// n < 30 — hide trend entirely, show data-insufficient state // n < 30 — hide trend entirely, show data-insufficient state
if (n < N_WEAK) { if (n < N_WEAK) {
return ( return (
<div <TrendCaveat
style={{ title={title}
borderRadius: 10, message={`Недостаточно данных (n=${n}) — расширьте радиус для анализа тренда`}
padding: "14px 16px", />
background: "#f3f4f6",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Тренд рынка в радиусе {trend.radius_km} км
</div>
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Недостаточно данных (n={n}) расширьте радиус для анализа тренда
</div>
</div>
); );
} }
const arrow = trendArrow(trend.delta_6m_pct); const delta = trend.delta_6m_pct;
const arrowColor = deltaColor(trend.delta_6m_pct); const arrow = trendArrow(delta);
const arrowColor = deltaColor(delta);
// Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее // Backend кладёт длинный label ("Сильный рост — рынок растёт быстрее
// инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу. // инфляции"); ключи LABEL_COLORS — короткие, поэтому нормализуем по префиксу.
const labelKey = trend.label.split(" — ")[0]; const label = trend.label ?? "";
const labelKey = label.split(" — ")[0];
const labelStyle = LABEL_COLORS[labelKey] ?? { const labelStyle = LABEL_COLORS[labelKey] ?? {
bg: "#f3f4f6", bg: "#f3f4f6",
color: "#374151", color: "#374151",
@ -103,7 +171,7 @@ export function MarketTrendBlock({ trend }: Props) {
}} }}
> >
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}> <div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
Тренд рынка в радиусе {trend.radius_km} км {title}
</div> </div>
{/* Main price */} {/* Main price */}
@ -136,8 +204,8 @@ export function MarketTrendBlock({ trend }: Props) {
> >
<span>{arrow}</span> <span>{arrow}</span>
<span> <span>
{trend.delta_6m_pct > 0 ? "+" : ""} {delta > 0 ? "+" : ""}
{trend.delta_6m_pct.toFixed(1)}% {delta.toFixed(1)}%
</span> </span>
</div> </div>
@ -148,21 +216,23 @@ export function MarketTrendBlock({ trend }: Props) {
</div> </div>
{/* Label badge */} {/* Label badge */}
<div {label && (
style={{ <div
display: "inline-block", style={{
borderRadius: 6, display: "inline-block",
padding: "3px 10px", borderRadius: 6,
background: labelStyle.bg, padding: "3px 10px",
color: labelStyle.color, background: labelStyle.bg,
fontSize: 12, color: labelStyle.color,
fontWeight: 600, fontSize: 12,
alignSelf: "flex-start", fontWeight: 600,
marginTop: 2, alignSelf: "flex-start",
}} marginTop: 2,
> }}
{trend.label} >
</div> {label}
</div>
)}
{/* Weak data warning: 30 ≤ n < 100 */} {/* Weak data warning: 30 ≤ n < 100 */}
{n < N_STRONG && ( {n < N_STRONG && (

View file

@ -38,6 +38,32 @@ function formatPercent(score: number): string {
return `${Math.round(score * 100)}%`; 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;
}
/** Словесный диапазон темпа: 033 «медленно» / 3366 «средне» / 66100 «быстро». */ /** Словесный диапазон темпа: 033 «медленно» / 3366 «средне» / 66100 «быстро». */
function paceBand(score: number): { label: string; color: string } { function paceBand(score: number): { label: string; color: string } {
if (score >= 0.66) if (score >= 0.66)
@ -64,6 +90,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
const scorePct = formatPercent(velocity.velocity_score); const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
const band = paceBand(velocity.velocity_score); const band = paceBand(velocity.velocity_score);
const coverageNote = coverageCaption(velocity);
// Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/ // Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
// медленнее рынка» по соотношению, отдельно от шкалы 0100%). // медленнее рынка» по соотношению, отдельно от шкалы 0100%).
const aboveMarket = ratio >= 1; const aboveMarket = ratio >= 1;
@ -197,6 +224,21 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
{Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес {Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес
(медиана ЕКБ) (медиана ЕКБ)
</div> </div>
{/* P2 (#1871) — caveat покрытия/прокси приглушённым caption-стилем */}
{coverageNote && (
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 4,
fontStyle: "italic",
}}
title="Доля объёма velocity, опирающаяся на прямые данные Objective; остальное оценено по прокси-коэффициенту м²/сделка."
>
{coverageNote}
</div>
)}
</div> </div>
)} )}

View file

@ -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 d = trend.delta_6m_pct;
const sign = d > 0 ? "+" : d < 0 ? "" : ""; const sign = d > 0 ? "+" : d < 0 ? "" : "";
const abs = Math.abs(d).toLocaleString("ru", { const abs = Math.abs(d).toLocaleString("ru", {

View file

@ -1163,17 +1163,32 @@ export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
: notReal("нет данных района"); : notReal("нет данных района");
const mt = a.market_trend; 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 = { const trend = {
field: field: mtComputed
mt != null ? real(
? real( `${mt!.delta_6m_pct! >= 0 ? "+" : ""}${fmtNum(mt!.delta_6m_pct!, 1)}%`,
`${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`, )
) : notReal(trendCaption),
: notReal("после прогноза"), recent: mtComputed ? formatRubPerM2(mt!.recent_avg_price_per_m2!) : DASH,
recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH, prior: mtComputed ? formatRubPerM2(mt!.prior_avg_price_per_m2!) : DASH,
prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH,
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH, count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
available: mt != null, available: mtComputed,
}; };
const v = a.velocity ?? null; const v = a.velocity ?? null;

View file

@ -175,14 +175,25 @@ export interface NeighborsSummary {
note?: string; note?: string;
} }
// P2 (#1871) — staleness/coverage статус источника сделок.
// "ok" — тренд посчитан; "source_stale" — источник сделок устарел (тренд не
// рисуем); "no_deals" — нет сделок в радиусе за период.
export type MarketTrendStatus = "ok" | "source_stale" | "no_deals";
export interface MarketTrend { export interface MarketTrend {
recent_avg_price_per_m2: number; // P2 (#1871): обязателен в новых ответах; optional для backward-compat со
prior_avg_price_per_m2: number; // старыми/кэшированными ответами без поля (трактуем отсутствие как "ok").
delta_6m_pct: number; status?: MarketTrendStatus;
// YYYY-MM-DD — дата последней доступной сделки источника (для caption staleness).
as_of_date?: string;
recent_deals_count: number; recent_deals_count: number;
prior_deals_count: number; // Поля ниже присутствуют только при status === "ok" (старый контракт).
label: string; recent_avg_price_per_m2?: number;
radius_km: 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). // Market pulse — district absorption + competitor counts (/analyze market layer).
@ -295,6 +306,13 @@ export interface Velocity {
// 'objective' — Objective (основной), 'rosreestr_fallback' — кадастровый квартал, // 'objective' — Objective (основной), 'rosreestr_fallback' — кадастровый квартал,
// 'none' — нет данных. // 'none' — нет данных.
velocity_source?: "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 // G5 (#32) — Gate verdict: can_build_mkd