fix(site-finder): velocity fallback 4500→750, coverage/proxy disclosure, market_trend staleness honesty (audit #1871 P2)
Три 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:
parent
85d91375dc
commit
adf3ec7c00
9 changed files with 376 additions and 78 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */
|
/** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */
|
||||||
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 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
|
||||||
// медленнее рынка» по соотношению, отдельно от шкалы 0–100%).
|
// медленнее рынка» по соотношению, отдельно от шкалы 0–100%).
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue