feat(site-finder): §4.3 тренд из цен предложения Объектива при устаревших сделках (#2178) (#2190)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-02 18:33:16 +00:00
parent 3b9dfdb44e
commit a3db6e4158
6 changed files with 768 additions and 3 deletions

View file

@ -72,6 +72,7 @@ from app.services.site_finder.custom_pois import (
from app.services.site_finder.developer_attribution import get_developer_attribution
from app.services.site_finder.gate_verdict import compute_gate_verdict
from app.services.site_finder.ird_analyze import build_ird_analyze_block
from app.services.site_finder.market_metrics import compute_offer_price_trend
from app.services.site_finder.osrm_client_local import (
OsrmLocalUnavailableError,
get_road_distances_m,
@ -2786,6 +2787,46 @@ def analyze_parcel(
# Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня.
stale_cutoff = _dt.date.today() - _dt.timedelta(days=90)
# #2178: ВТОРОЙ источник — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history
# (ЖК в том же радиусе 3 км). Живёт, когда rosreestr встал (сделки stale).
# Считаем всегда; ниже решаем, основной он или дополнительный к сделкам.
# Своя savepoint — offer-запрос не должен ронять уже посчитанный сделки-тренд.
offer_block: dict[str, Any] | None = None
try:
with db.begin_nested():
# Предохранитель: на плотном центре ЕКБ холодный прогон доходит
# до ~5.5 с (EXPLAIN 2026-07-02) — эндпоинт синхронный, дольше
# 8 с ждать нельзя. Таймаут → QueryCanceled → откат savepoint →
# offer_block=None (прежнее поведение). После запроса возвращаем
# серверный дефолт (0, проверено на проде), т.к. RELEASE SAVEPOINT
# сохранил бы SET LOCAL до конца внешней транзакции.
db.execute(text("SET LOCAL statement_timeout = '8000'"))
offer_trend = compute_offer_price_trend(
db, center_lat=centroid_lat, center_lon=centroid_lon
)
db.execute(text("SET LOCAL statement_timeout = 0"))
if offer_trend.has_data:
_offer_delta = offer_trend.delta_pct
if _offer_delta is None:
_offer_label = "Динамика цен предложения (Объектив)"
elif _offer_delta > 8:
_offer_label = "Цены предложения заметно растут"
elif _offer_delta > 0:
_offer_label = "Цены предложения умеренно растут"
elif _offer_delta > -5:
_offer_label = "Цены предложения стабильны"
else:
_offer_label = "Цены предложения снижаются"
offer_block = {
"source": "Объектив, цены предложения",
"label": _offer_label,
"radius_km": 3,
**offer_trend.as_dict(),
}
except Exception as e:
logger.warning("offer_price_trend query failed for %s: %s", cad_num, e)
offer_block = None
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
# Happy path — есть данные в обоих окнах.
recent_p = float(trend_row["recent_avg"])
@ -2810,8 +2851,39 @@ def analyze_parcel(
"label": perspective_label,
"radius_km": 3,
}
# #2178: Росреестр свежий → показываем ОБА блока (сделки + предложение).
if offer_block is not None:
market_trend["offer_trend"] = offer_block
elif offer_block is not None:
# #2178: сделки устарели/отсутствуют, НО есть живая динамика предложения —
# она становится ОСНОВНЫМ содержимым карточки (status='offer_only').
# as_of_date отдаём как дату последнего снапшота предложения, чтобы фронт
# честно показал «по состоянию на …». Сохраняем максимум контекста сделок.
_offer_as_of = offer_block["points"][-1]["date"] if offer_block["points"] else None
_deals_stale = (
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
)
# Разные причины отсутствия сделок → честная микрокопия: устаревание
# источника vs просто нет сделок рядом (источник свеж). Обе показывают
# предложение как основной контент.
_offer_only_label = (
"Сделки устарели — показываем динамику цен предложения"
if _deals_stale
else "Нет сделок рядом — показываем динамику цен предложения"
)
market_trend = {
"status": "offer_only",
"label": _offer_only_label,
"deals_stale": _deals_stale,
"recent_deals_count": recent_n_val,
"deals_as_of_date": (max_period.isoformat() if max_period is not None else None),
"as_of_date": _offer_as_of,
"offer_trend": offer_block,
}
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
# прежний source_stale остаётся честным «нечего показать».
market_trend = {
"status": "source_stale",
"as_of_date": max_period.isoformat(),

View file

@ -753,6 +753,9 @@ class AnalyzeResponse(BaseModel):
market_data_coverage_pct: float | None = None
pipeline_24mo: dict[str, Any] | None = None
velocity: dict[str, Any] | None = None
# market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
market_trend: dict[str, Any] | None = None
market_price: dict[str, Any] | None = None

View file

@ -716,3 +716,218 @@ def _price_sensitivity(
)
return None, None
return float(elast["elasticity"]), str(elast["source"])
# ──────────────────────────────────────────────────────────────────────────────
# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history.
#
# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал
# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая
# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных
# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем
# карточку, когда сделки устарели.
#
# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ
# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см.
# 131_district_vocab_map.sql):
# radius (ST_DWithin по domrf_kn_objects)
# → domrf_obj_id
# → objective_complex_mapping.domrf_obj_id → objective_complex_name
# → objective_lots.project_name → objective_lot_id-набор
# → objective_lots_history (per-snapshot цены этих лотов).
# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id
# фиксируем из objective_lots (текущее состояние), а историю берём по нему.
#
# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды
# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в
# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде
# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки
# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC
# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору,
# иначе задвоенный день весит ×2 и смещает медиану.
#
# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history —
# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен
# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем
# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не
# используем: в истории он часто NULL у активных лотов, а нам важна котировка.
#
# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает
# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у
# сделок-тренда (30k500k), т.к. предложение включает премиум/апартаменты.
_OFFER_TREND_SQL = text(
"""
WITH radius_obj AS (
SELECT DISTINCT ON (o.obj_id) o.obj_id
FROM domrf_kn_objects o
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:center_lon AS float), CAST(:center_lat AS float)
),
4326
)::geography,
CAST(:radius_m AS float)
)
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
),
radius_projects AS (
SELECT DISTINCT cm.objective_complex_name AS project_name
FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj)
),
radius_lots AS (
SELECT DISTINCT ol.objective_lot_id
FROM objective_lots ol
JOIN radius_projects rp ON rp.project_name = ol.project_name
WHERE ol.premise_kind = CAST(:premise_kind AS text)
),
hist AS (
-- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип.
SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date)
h.snapshot_date,
h.price_per_m2_rub
FROM objective_lots_history h
JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id
WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric)
AND CAST(:price_max AS numeric)
AND (h.is_sold IS NOT TRUE)
AND LOWER(COALESCE(h.status, '')) <> 'продан'
ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
)
SELECT
snapshot_date AS snapshot_date,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2,
COUNT(*) AS lots_count,
(SELECT COUNT(*) FROM radius_projects) AS complexes_covered
FROM hist
GROUP BY snapshot_date
ORDER BY snapshot_date
"""
)
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k500k):
# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор.
_OFFER_PRICE_MIN: int = 10000
_OFFER_PRICE_MAX: int = 1000000
# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида).
_OFFER_TREND_RADIUS_M: int = 3000
@dataclass(frozen=True)
class OfferPriceTrend:
"""Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178).
Источник objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе).
Всегда конструируется через ``from_points``; пустой набор точек None-поля,
``points=[]`` (никогда не бросает на отсутствии данных).
"""
points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}]
delta_pct: float | None # (последняя медиана первая) / первая × 100
lots_latest: int | None # lots_count последнего снапшота
complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку
def as_dict(self) -> dict[str, Any]:
return {
"points": self.points,
"delta_pct": _round_or_none(self.delta_pct, 1),
"lots_latest": self.lots_latest,
"complexes_covered": self.complexes_covered,
}
@property
def has_data(self) -> bool:
"""True если есть ≥1 снапшот с медианой — тогда карточку можно показать."""
return len(self.points) > 0
def compute_offer_price_trend(
db: Session,
*,
center_lat: float,
center_lon: float,
radius_m: int = _OFFER_TREND_RADIUS_M,
premise_kind: str = "квартира",
) -> OfferPriceTrend:
"""Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178).
ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в
радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects
objective_complex_mapping objective_lots. По их лотам считается per-snapshot
медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр
вменяемости цены).
Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None,
complexes_covered=0). Никогда не бросает на отсутствии данных на ошибке SQL
логирует и возвращает пустой результат (graceful, зеркало _query_stock).
"""
params: dict[str, Any] = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"radius_m": float(radius_m),
"premise_kind": premise_kind,
"price_min": _OFFER_PRICE_MIN,
"price_max": _OFFER_PRICE_MAX,
}
try:
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
except Exception:
logger.exception(
"market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)",
center_lat,
center_lon,
radius_m,
)
rows = []
points: list[dict[str, Any]] = []
complexes_covered = 0
for r in rows:
# complexes_covered — константа по всем строкам (scalar-subquery), берём
# из первой встреченной строки. При пустом hist rows=[] → остаётся 0.
complexes_covered = int(r["complexes_covered"] or 0)
median = r["median_price_m2"]
if median is None:
continue
snap = r["snapshot_date"]
# snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того,
# date или datetime отдал драйвер.
date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap)
points.append(
{
"date": date_str,
"median_price_m2": round(float(median)),
"lots_count": int(r["lots_count"] or 0),
}
)
delta_pct: float | None = None
lots_latest: int | None = None
if points:
first_median = points[0]["median_price_m2"]
last_median = points[-1]["median_price_m2"]
lots_latest = points[-1]["lots_count"]
if first_median and first_median > 0:
delta_pct = (last_median - first_median) / first_median * 100.0
logger.info(
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
"lots_latest=%s delta_pct=%s",
center_lat,
center_lon,
radius_m,
len(points),
lots_latest,
_round_or_none(delta_pct, 1),
)
return OfferPriceTrend(
points=points,
delta_pct=delta_pct,
lots_latest=lots_latest,
complexes_covered=complexes_covered,
)

View file

@ -13,6 +13,7 @@ psycopg v3 правило проверяется явно: bind-параметр
from __future__ import annotations
import datetime as _dt
import os
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
@ -23,6 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.services.site_finder.market_metrics import (
MarketMetrics,
OfferPriceTrend,
_absorption_rate,
_confidence,
_demand_concentration,
@ -34,6 +36,7 @@ from app.services.site_finder.market_metrics import (
_room_bucket,
_sell_through_pct,
compute_market_metrics,
compute_offer_price_trend,
)
_ELAST = "app.services.site_finder.market_metrics._elasticity_coef"
@ -811,3 +814,199 @@ class TestSalesWindowSource:
assert m.n_sold == 41_091
assert m.sell_through_pct is not None
assert round(m.sell_through_pct, 1) == round(41_091 / 50_000 * 100, 1)
# ── compute_offer_price_trend (#2178): динамика цен предложения ────────────────
# objective_lots_history-медианы. Единственный execute → .mappings().all().
# Медиана считается в SQL (PERCENTILE_CONT) — mock подаёт УЖЕ посчитанные строки
# per-snapshot, тест проверяет форму SQL (дедуп/фильтры/CAST) и Python-обвязку
# (delta_pct, lots_latest, complexes_covered, порядок, graceful-on-empty).
def _mock_offer_db(rows: list[dict]) -> MagicMock:
"""Сессия с одним execute (offer-trend) → .mappings().all() = rows."""
db = MagicMock()
result = MagicMock()
result.mappings.return_value.all.return_value = rows
db.execute.return_value = result
return db
class TestOfferPriceTrendSql:
"""Форма SQL: psycopg v3 CAST, дедуп задвоенного снапшота, фильтры, radius-путь."""
def test_sql_uses_cast_not_double_colon(self) -> None:
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
sql = _executed_sql(db, 0)
assert "CAST(:center_lon AS float)" in sql
assert "CAST(:center_lat AS float)" in sql
assert "CAST(:radius_m AS float)" in sql
assert "CAST(:premise_kind AS text)" in sql
assert "CAST(:price_min AS numeric)" in sql
assert "CAST(:price_max AS numeric)" in sql
for bind in ("center_lon", "center_lat", "radius_m", "price_min", "price_max"):
assert f":{bind}::" not in sql
def test_sql_dedups_doubled_snapshot(self) -> None:
# Дедуп задвоенного дня: DISTINCT ON (лот, день) ORDER BY fetched_at DESC.
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
sql = _executed_sql(db, 0)
assert "DISTINCT ON (h.objective_lot_id, h.snapshot_date)" in sql
assert "h.fetched_at DESC" in sql
def test_sql_filters_active_and_price_sanity(self) -> None:
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
sql = _executed_sql(db, 0)
# «в продаже»: не продан флагом и не 'продан' статусом.
assert "h.is_sold IS NOT TRUE" in sql
assert "LOWER(COALESCE(h.status, '')) <> 'продан'" in sql
# Вменяемость цены — bind-границы (10000..1000000).
assert "price_per_m2_rub BETWEEN CAST(:price_min AS numeric)" in sql
# Радиус-путь через маппинг (НЕ complex_id-FK).
assert "objective_complex_mapping" in sql
assert "domrf_kn_objects" in sql
assert "ST_DWithin" in sql
assert "PERCENTILE_CONT(0.5)" in sql
def test_params_center_radius_price(self) -> None:
db = _mock_offer_db([])
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
p = _executed_params(db, 0)
assert p["center_lat"] == 56.83
assert p["center_lon"] == 60.6
assert p["radius_m"] == 3000.0
assert p["premise_kind"] == "квартира"
assert p["price_min"] == 10000
assert p["price_max"] == 1000000
class TestOfferPriceTrendCompute:
"""Python-обвязка: медианы по снапшотам, delta_pct, lots_latest, covered."""
def _rows(self) -> list[dict]:
# 3 снапшота, медиана растёт 100k → 110k; covered=5 (константа scalar-subq).
return [
{
"snapshot_date": _dt.date(2026, 5, 17),
"median_price_m2": 100_000.0,
"lots_count": 120,
"complexes_covered": 5,
},
{
"snapshot_date": _dt.date(2026, 6, 3),
"median_price_m2": 105_000.0,
"lots_count": 118,
"complexes_covered": 5,
},
{
"snapshot_date": _dt.date(2026, 6, 30),
"median_price_m2": 110_000.0,
"lots_count": 130,
"complexes_covered": 5,
},
]
def test_points_shape_and_order(self) -> None:
db = _mock_offer_db(self._rows())
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert isinstance(res, OfferPriceTrend)
assert [pt["date"] for pt in res.points] == ["2026-05-17", "2026-06-03", "2026-06-30"]
assert [pt["median_price_m2"] for pt in res.points] == [100_000, 105_000, 110_000]
assert [pt["lots_count"] for pt in res.points] == [120, 118, 130]
def test_delta_pct_last_vs_first(self) -> None:
db = _mock_offer_db(self._rows())
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
# (110000 100000) / 100000 × 100 = 10.0
assert res.delta_pct == 10.0
def test_lots_latest_and_covered(self) -> None:
db = _mock_offer_db(self._rows())
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.lots_latest == 130
assert res.complexes_covered == 5
assert res.has_data is True
def test_single_snapshot_delta_zero(self) -> None:
rows = [
{
"snapshot_date": _dt.date(2026, 6, 30),
"median_price_m2": 90_000.0,
"lots_count": 40,
"complexes_covered": 2,
}
]
db = _mock_offer_db(rows)
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
# Один снапшот: first==last → delta 0.0 (не None, не crash).
assert res.delta_pct == 0.0
assert res.lots_latest == 40
assert len(res.points) == 1
def test_null_median_rows_skipped(self) -> None:
# Снапшот, где медиана NULL (все лоты отфильтрованы) — не попадает в points,
# но complexes_covered всё равно считывается (радиус покрыт).
rows = [
{
"snapshot_date": _dt.date(2026, 5, 17),
"median_price_m2": None,
"lots_count": 0,
"complexes_covered": 3,
},
{
"snapshot_date": _dt.date(2026, 6, 30),
"median_price_m2": 95_000.0,
"lots_count": 22,
"complexes_covered": 3,
},
]
db = _mock_offer_db(rows)
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert [pt["date"] for pt in res.points] == ["2026-06-30"]
assert res.complexes_covered == 3
assert res.delta_pct == 0.0 # один валидный снапшот
def test_empty_complex_set_returns_empty(self) -> None:
# Пустой набор ЖК в радиусе → rows=[] → пустой результат, НЕ crash/None-поля.
db = _mock_offer_db([])
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
assert res.points == []
assert res.delta_pct is None
assert res.lots_latest is None
assert res.complexes_covered == 0
assert res.has_data is False
def test_query_exception_graceful(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
# Ошибка SQL → пустой результат (graceful), не пробрасывается.
assert res.points == []
assert res.has_data is False
def test_as_dict_rounds_delta(self) -> None:
rows = [
{
"snapshot_date": _dt.date(2026, 5, 17),
"median_price_m2": 100_000.0,
"lots_count": 10,
"complexes_covered": 1,
},
{
"snapshot_date": _dt.date(2026, 6, 30),
"median_price_m2": 103_333.0,
"lots_count": 12,
"complexes_covered": 1,
},
]
db = _mock_offer_db(rows)
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
d = res.as_dict()
assert set(d.keys()) == {"points", "delta_pct", "lots_latest", "complexes_covered"}
# (103333 100000)/100000×100 = 3.333 → round(1) = 3.3
assert d["delta_pct"] == 3.3
assert d["lots_latest"] == 12
assert d["complexes_covered"] == 1

View file

@ -1,6 +1,11 @@
"use client";
import type { MarketTrend, MarketTrendStatus } from "@/types/site-finder";
import type {
MarketTrend,
MarketTrendStatus,
OfferTrend,
OfferTrendPoint,
} from "@/types/site-finder";
interface Props {
trend?: MarketTrend | null;
@ -63,6 +68,204 @@ function formatAsOfMonth(asOf: string | undefined): string | null {
return `${RU_MONTHS[monthIdx]} ${year}`;
}
/**
* #2178: дата YYYY-MM-DD «DD.MM» или «DD.MM.YYYY» (при withYear). Парсим
* компоненты напрямую НЕ через new Date() (TZ-сдвиг на полночь UTC).
*/
function formatShortDate(iso: string, withYear: boolean): string | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return null;
const [, year, month, day] = m;
return withYear ? `${day}.${month}.${year}` : `${day}.${month}`;
}
/** #2178: «DD.MMDD.MM.YYYY» для диапазона точек тренда (минус — Unicode en-dash). */
function formatPeriod(points: OfferTrendPoint[]): string | null {
if (points.length === 0) return null;
const first = formatShortDate(points[0].date, false);
const last = formatShortDate(points[points.length - 1].date, true);
if (first == null || last == null) return null;
return `${first}${last}`;
}
/**
* #2178: знаковый delta-процент с Unicode-минусом (, не дефис) и одним знаком.
* `+8.1%` / `8.1%` / `0.0%`.
*/
function formatSignedPct(delta: number): string {
const rounded = delta.toFixed(1);
if (delta > 0) return `+${rounded}%`;
if (delta < 0) return `${rounded.replace("-", "")}%`;
return `${rounded}%`;
}
/** #2178: цвет delta-бейджа предложения — danger при падении, success при росте. */
function offerDeltaStyle(delta: number): { bg: string; color: string } {
if (delta > 1) return { bg: "#dcfce7", color: "#15803d" };
if (delta < -1) return { bg: "#fecaca", color: "#b91c1c" };
return { bg: "#f3f4f6", color: "#374151" };
}
/**
* #2178: SVG-спарклайн по точкам медианы предложения. Одиночная линия цвет
* #374151 (правило ui-tokens: цвет серии только для сравнения). Без зависимостей.
* Каждая точка <title> с датой и ценой «/м²» (native hover-tooltip с единицей).
*/
function OfferSparkline({ points }: { points: OfferTrendPoint[] }) {
const W = 260;
const H = 48;
const PAD = 4;
if (points.length < 2) return null;
const values = points.map((p) => p.median_price_m2);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const stepX = (W - PAD * 2) / (points.length - 1);
const coords = points.map((p, i) => {
const x = PAD + i * stepX;
// Инвертируем Y: выше цена — выше на графике (меньше y).
const y = PAD + (1 - (p.median_price_m2 - min) / span) * (H - PAD * 2);
return { x, y };
});
const line = coords
.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`)
.join(" ");
return (
<svg
width="100%"
height={H}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
role="img"
aria-label="Динамика медианной цены предложения, ₽/м²"
style={{ display: "block", overflow: "visible" }}
>
<polyline
points={line}
fill="none"
stroke="#374151"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{coords.map((c, i) => (
<circle key={i} cx={c.x} cy={c.y} r={2} fill="#374151">
<title>
{formatShortDate(points[i].date, true) ?? points[i].date} ·{" "}
{fmtPrice(points[i].median_price_m2)} /м²
</title>
</circle>
))}
</svg>
);
}
/**
* #2178: полноценная карточка тренда по предложению (Объектив) для status
* "offer_only". Нейтральные токены (--fg-primary / --bg-card), НЕ выглядит
* ошибкой. deals_stale управляет второй строкой-caveat.
*/
function OfferOnlyCard({
title,
offer,
dealsStale,
dealsAsOfDate,
}: {
title: string;
offer: OfferTrend;
dealsStale: boolean;
dealsAsOfDate: string | null | undefined;
}) {
const latest = offer.points[offer.points.length - 1];
const delta = offer.delta_pct;
const period = formatPeriod(offer.points);
const deltaStyle = delta != null ? offerDeltaStyle(delta) : null;
// Источник — полным предложением: «По ценам предложения N ЖК рядом (Объектив,
// M лотов) · DD.MMDD.MM.YYYY».
const lots = offer.lots_latest;
const sourceParts = [
`По ценам предложения ${offer.complexes_covered} ЖК рядом`,
lots != null ? `(Объектив, ${fmtPrice(lots)} лотов)` : "(Объектив)",
];
const sourceLine =
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
// Caveat — почему показываем предложение вместо сделок.
const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined);
const caveat = dealsStale
? dealsMonth != null
? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.`
: "Сделки Росреестра устарели — показана динамика цен предложения."
: "Сделок Росреестра рядом нет — показана динамика цен предложения.";
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "#ffffff",
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
{title}
</div>
{/* Main value: текущая медиана предложения + delta-бейдж за период */}
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{
fontSize: 24,
fontWeight: 800,
color: "#111827",
lineHeight: 1,
}}
>
{fmtPrice(latest.median_price_m2)} /м²
</span>
{delta != null && deltaStyle != null && (
<span
style={{
display: "inline-block",
borderRadius: 6,
padding: "2px 8px",
background: deltaStyle.bg,
color: deltaStyle.color,
fontSize: 13,
fontWeight: 700,
}}
>
{formatSignedPct(delta)}
</span>
)}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: -4 }}>
медиана предложения · за период
</div>
{/* Мини-график динамики предложения */}
<div style={{ marginTop: 2 }}>
<OfferSparkline points={offer.points} />
</div>
{/* Источник полным предложением + caveat */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontSize: 11, color: "#6b7280" }}>{sourceLine}</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>{caveat}</div>
</div>
</div>
);
}
/** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */
function TrendCaveat({ title, message }: { title: string; message: string }) {
return (
@ -100,6 +303,29 @@ export function MarketTrendBlock({ trend }: Props) {
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
const title = `Тренд рынка${radiusSuffix}`;
// #2178: сделки устарели/отсутствуют, но живёт динамика предложения —
// полноценная карточка тренда по предложению (Объектив), НЕ caveat-тупик.
if (status === "offer_only") {
const offer = trend.offer_trend;
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
if (offer && offer.points.length > 0) {
return (
<OfferOnlyCard
title={title}
offer={offer}
dealsStale={trend.deals_stale ?? false}
dealsAsOfDate={trend.deals_as_of_date}
/>
);
}
return (
<TrendCaveat
title={title}
message="Источник сделок устарел — тренд не показываем."
/>
);
}
// P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда.
if (status === "source_stale") {
const asOfMonth = formatAsOfMonth(trend.as_of_date);
@ -234,6 +460,24 @@ export function MarketTrendBlock({ trend }: Props) {
</div>
)}
{/* #2178: компактная вторая строка динамики предложения под сделко-трендом. */}
{trend.offer_trend &&
trend.offer_trend.points.length > 0 &&
(() => {
const offer = trend.offer_trend;
const latest = offer.points[offer.points.length - 1];
const deltaTxt =
offer.delta_pct != null
? ` · Δ ${formatSignedPct(offer.delta_pct)}`
: "";
return (
<div style={{ fontSize: 11, color: "#6b7280" }}>
Предложение: медиана {fmtPrice(latest.median_price_m2)} /м²
{deltaTxt} (Объектив)
</div>
);
})()}
{/* Weak data warning: 30 ≤ n < 100 */}
{n < N_STRONG && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 text-xs px-2 py-1 rounded">

View file

@ -200,14 +200,38 @@ export interface NeighborsSummary {
// P2 (#1871) — staleness/coverage статус источника сделок.
// "ok" — тренд посчитан; "source_stale" — источник сделок устарел (тренд не
// рисуем); "no_deals" — нет сделок в радиусе за период.
export type MarketTrendStatus = "ok" | "source_stale" | "no_deals";
// рисуем); "no_deals" — нет сделок в радиусе за период;
// "offer_only" (#2178) — сделки устарели/отсутствуют, но есть живая динамика
// цен предложения (Объектив) → она становится основным содержимым карточки.
export type MarketTrendStatus =
"ok" | "source_stale" | "no_deals" | "offer_only";
// #2178 — одна точка снапшота динамики цен предложения (Объектив).
export interface OfferTrendPoint {
date: string; // YYYY-MM-DD (дата weekly-снапшота)
median_price_m2: number; // медианная цена предложения, ₽/м²
lots_count: number; // сколько активных лотов в снапшоте
}
// #2178 — динамика цен предложения (Объектив): саб-блок при status "ok" и
// основной контент при status "offer_only". Форма зеркалит OfferPriceTrend
// .as_dict() + метаданные offer_block (source/label/radius_km) из хендлера.
export interface OfferTrend {
source: string; // «Объектив, цены предложения»
label: string; // человекочитаемый вердикт по динамике
radius_km: number;
points: OfferTrendPoint[];
delta_pct: number | null; // (последняя медиана первая) / первая × 100
lots_latest: number | null; // lots_count последнего снапшота
complexes_covered: number; // сколько ЖК попало в радиус-выборку
}
export interface MarketTrend {
// P2 (#1871): обязателен в новых ответах; optional для backward-compat со
// старыми/кэшированными ответами без поля (трактуем отсутствие как "ok").
status?: MarketTrendStatus;
// YYYY-MM-DD — дата последней доступной сделки источника (для caption staleness).
// При status "offer_only" — дата последнего снапшота предложения.
as_of_date?: string;
recent_deals_count: number;
// Поля ниже присутствуют только при status === "ok" (старый контракт).
@ -217,6 +241,14 @@ export interface MarketTrend {
prior_deals_count?: number;
label?: string;
radius_km?: number;
// #2178 — динамика цен предложения. При status "ok" — дополнительный саб-блок
// под сделко-трендом; при "offer_only" — основной контент карточки.
offer_trend?: OfferTrend | null;
// #2178 (только status "offer_only"): устарел ли источник сделок (true) или
// сделок просто нет рядом при живом источнике (false).
deals_stale?: boolean;
// #2178 (только status "offer_only"): YYYY-MM-DD последней сделки источника.
deals_as_of_date?: string | null;
}
// Market pulse — district absorption + competitor counts (/analyze market layer).