Merge pull request 'fix(site-finder): velocity fallback 4500→750, coverage/proxy disclosure, market_trend staleness honesty (audit #1871 P2)' (#1876) from audit/1871-p2-velocity-market into main
This commit is contained in:
commit
c698af68ce
9 changed files with 376 additions and 78 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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) {
|
||||
if (!trend) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
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>
|
||||
<TrendCaveat
|
||||
title="Тренд рынка"
|
||||
message="Недостаточно сделок ДДУ в радиусе для тренда"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Без числовых полей (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
|
||||
if (n < N_WEAK) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
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>
|
||||
<TrendCaveat
|
||||
title={title}
|
||||
message={`Недостаточно данных (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) {
|
|||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||
Тренд рынка в радиусе {trend.radius_km} км
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Main price */}
|
||||
|
|
@ -136,8 +204,8 @@ export function MarketTrendBlock({ trend }: Props) {
|
|||
>
|
||||
<span>{arrow}</span>
|
||||
<span>
|
||||
{trend.delta_6m_pct > 0 ? "+" : ""}
|
||||
{trend.delta_6m_pct.toFixed(1)}%
|
||||
{delta > 0 ? "+" : ""}
|
||||
{delta.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -148,21 +216,23 @@ export function MarketTrendBlock({ trend }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Label badge */}
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
padding: "3px 10px",
|
||||
background: labelStyle.bg,
|
||||
color: labelStyle.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
alignSelf: "flex-start",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{trend.label}
|
||||
</div>
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
padding: "3px 10px",
|
||||
background: labelStyle.bg,
|
||||
color: labelStyle.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
alignSelf: "flex-start",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weak data warning: 30 ≤ n < 100 */}
|
||||
{n < N_STRONG && (
|
||||
|
|
|
|||
|
|
@ -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")} м²/мес
|
||||
(медиана ЕКБ)
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue