feat(site-finder): тренд §4.3 из цен предложения Объектива при устаревших сделках (#2178)
rosreestr_deals встали на 2026-01 → карточка «Тренд рынка» почти всегда source_stale-тупик. Добавлен второй источник: медианная динамика ₽/м² по objective_lots_history для ЖК того же 3км-радиуса (через objective_complex_mapping, как в best_layouts). - compute_offer_price_trend: дедуп задвоенного снапшота (DISTINCT ON + fetched_at DESC), фильтры активных лотов и вменяемости цены, медиана PERCENTILE_CONT per snapshot, delta_pct - эндпоинт: саб-блок offer_trend при ok; новый статус offer_only когда сделки stale но предложение живо; source_stale — только когда нет ничего; SET LOCAL statement_timeout 8s вокруг offer-запроса (холодный прогон на плотном центре ~5.5с — при таймауте честный откат к прежнему поведению) - фронт: карточка offer_only (медиана + delta-бейдж + SVG-спарклайн + полная подпись источника и caveat), компакт-строка под ok-трендом - 12 unit-тестов backend; smoke на прод-БД: 6 точек, 7 ЖК, 146.8k→135.0k ₽/м² (−8.1%) для участка 66:41:0204016:10
This commit is contained in:
parent
abdb413a99
commit
252affa139
6 changed files with 768 additions and 3 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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). Границы шире, чем у
|
||||
# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты.
|
||||
_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
|
||||
"""
|
||||
)
|
||||
|
||||
# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k):
|
||||
# предложение включает премиум/апартаменты; отсекаем только явный 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.MM–DD.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.MM–DD.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">
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue