GET /api/v1/property-objects/{propertyObjectId}/price-history — История цены объекта недвижимости (график роста)
Назначение
Отдаёт ряды точек изменения цены объекта недвижимости из ленты
PropertyObjectPriceHistoryFact (ClickHouse) с грануляцией
day/month/year и метрикой growthPercent (рост цены м² от первой к последней точке диапазона).
Питает график роста цены на странице /lots/[id]/analytics и динамику портфеля investor-owner
(docs/06-services/investor-owner/api/get-my-property.md читает этот метод). Метод не зависит от
chessboard и PostgreSQL — работает по накопленной ленте ClickHouse.
| Поле | Значение |
|---|
| Сервис/контекст | invest-analytics |
| API area | public |
| Feature group | price-history |
| Статус | draft |
| Документ | docs/06-services/invest-analytics/api/public/get-property-object-price-history.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT: инвестор/агент/менеджер) или Anonymous реф-гость (валидная реферальная cookie referralUrl, 30 дней — гейт каталога, docs/06-services/catalog/README.md) |
| Auth policy / Permissions | policy CatalogAccess (JWT или реф-cookie); permission invest-analytics.price-history.view (выдана ролям каталога; реф-гость получает её implicit через policy) — каталог permissions: docs/06-services/identity/domain/permissions.md |
| Scope (RBAC) | платформа (цены публичны в каталоге) |
| Tenant isolation | чтение не фильтруется по TenantId факта (TenantId = DeveloperId — только колонка ленты); request не tenanted |
| Audit | none (public read, PII нет) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/property-objects/{propertyObjectId}/price-history |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Диапазон дат — единый диапазон NullableRange<DateOnly> (пара from/to — один параметр периода;
wire-параметры from/to задают границы Period.From/Period.To).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PropertyObjectId | route | Guid | Да | != Guid.Empty | — |
Granularity | query granularity | PriceHistoryGranularity (enum) | Нет | wire day / month / year; default month; неизвестное значение → ValidationError (молчаливый дефолт запрещён) | — |
Period | query from + query to | NullableRange<DateOnly> | Нет | ISO yyyy-MM-dd; Period.From <= Period.To; для day при заданных обеих границах диапазон ≤ 731 дня (guard числа точек) | — |
Дефолты Period: Period.To — сегодня (UTC), если не задан. Period.From — если не задан: day →
Period.To − 365 дней, month/year → null (вся история от первого факта). Enum
PriceHistoryGranularity: Day / Month / Year (wire — строки нижнего регистра).
Модель ответа PropertyObjectPriceHistoryResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
propertyObjectId | Guid | Да | echo route | — | Объект недвижимости. |
granularity | string (enum wire) | Да | echo (после дефолта) | — | Применённая грануляция day/month/year. |
from | string? (ISO date) | Нет | echo Period.From / дата первой точки, если не задан; null при пустом ряде | — | Фактическое начало диапазона. |
to | string (ISO date) | Да | echo Period.To (после дефолта) | — | Конец диапазона. |
points | IReadOnlyList<PricePointModel> | Да (может быть пуст) | агрегация ленты цен | — | Точки ряда по возрастанию даты; пустой список = истории по объекту недвижимости нет. |
growthPercent | decimal? | Нет | вычисление | — | Рост цены м² от первой к последней точке диапазона, %, 1 знак; null, если точек < 2 или первая цена = 0. |
PricePointModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
date | string (ISO date) | Да | начало периода бакета (toStartOfDay/Month/Year(EventTime)) | — | Дата точки. |
price | decimal | Да | argMax(NewPrice, (EventTime, IngestionTime)) внутри бакета | — | Последняя полная цена объекта недвижимости в периоде, руб. |
pricePerMeter | decimal | Да | argMax(NewPricePerMeter, (EventTime, IngestionTime)) внутри бакета | — | Последняя цена м² в периоде, руб/м². |
Алгоритм
- Контекст и доступ. Actor через policy
CatalogAccess (Bearer JWT или валидная реф-cookie —
отсутствие обоих → 401 UNAUTHORIZED); scope — платформа (цены публичны в каталоге).
- Проверка входа.
PropertyObjectId != Guid.Empty; Granularity ∈ {day,month,year};
Period.From <= Period.To; для day при заданных обеих границах — диапазон ≤ 731 дня → иначе
ValidationError (400). Существование объекта недвижимости сознательно не проверяется — метод не
обращается к chessboard (деградационная устойчивость): неизвестный/новый PropertyObjectId → пустой
points. Допустимость состояния и scope не проверяются (append-only факт без статусов, данные публичны).
- Что читаем. Один запрос в ленту цен ClickHouse
pin_analytics.property_object_price_history_facts:
точки по PropertyObjectId в диапазоне дат (при Period.From = null — без нижней границы),
сгруппированные по бакету toStartOfDay|toStartOfMonth|toStartOfYear(EventTime); значение бакета —
последняя полная цена argMax(NewPrice, (EventTime, IngestionTime)) и последняя цена м²
argMax(NewPricePerMeter, (EventTime, IngestionTime)) (дубль-устойчиво к ReplacingMergeTree, FINAL
запрещён), по возрастанию бакета. Партиционный ключ toYYYYMM(EventTime) режет диапазон;
PostgreSQL не участвует.
- Правила и маппинг.
points[k].date ← бакет (ISO); price/pricePerMeter ← argMax-значения.
growthPercent: если точек ≥ 2 и первая цена м² > 0 →
round1((points[последний].pricePerMeter / points[0].pricePerMeter − 1) × 100); иначе null (guard
деления в стиле INV-CALC-1, ../../domain/investor-series-models.md).
from ответа ← явный Period.From либо дата первой точки; пустой ряд → from = null,
growthPercent = null.
- Результат. Ряд точек +
growthPercent. Freshness: лента отстаёт от Talent не более чем на SLA
консюмера (≤ 5 мин, ../../domain/price-history-fact.md);
недоступность ClickHouse → INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Невалидные granularity/from/to/диапазон day > 731 дня | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет JWT и нет валидной реф-cookie | Требуется авторизация или реферальная ссылка | no retry |
INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE | 503 | ClickHouse недоступен / timeout запроса | История цен временно недоступна | retry с backoff |
Пустая история объекта недвижимости — не ошибка: 200 с points = [].
Совместимость и наблюдаемость
- Новые optional поля ответа — additive (без breaking); удаление/переименование полей, смена wire-значений
granularity — breaking (версия метода). Unknown enum на входе → ValidationError (не дефолт).
- Logs:
price-history-read (propertyObjectId, granularity, pointsCount, elapsed; PII нет). Metrics:
invest_analytics_price_history_requests_total{granularity}, latency histogram (SLO p95 ≤ 200 мс).
Traces: span приёма → span запроса к ленте цен; correlation через сквозной trace traceparent.
Dashboard/runbook — борд контекста (ingest lag влияет на свежесть ряда).
Acceptance Criteria
- Given по объекту недвижимости ≥ 2 фактов,
granularity = month, When GET, Then 200 с
points по возрастанию даты и growthPercent (1 знак) = рост цены м² от первой к последней точке.
- Given объект недвижимости неизвестен/новый (истории нет), When GET, Then
200, points = [],
from = null, growthPercent = null; chessboard не вызывается (метод устойчив к отсутствию
объекта в каталоге).
- Given
granularity = day и обе границы заданы с диапазоном > 731 дня, When GET, Then 400 ValidationError (guard числа точек).
- Given неизвестное значение
granularity, When GET, Then 400 ValidationError (молчаливый
дефолт запрещён); from > to → 400.
- Given точек < 2 или первая цена м² = 0, When GET, Then
growthPercent = null (guard
деления).
- Given ClickHouse недоступен/timeout, When GET, Then
503 INVEST_ANALYTICS_PRICE_HISTORY_UNAVAILABLE (retry с backoff).
- Given реф-гость с валидной cookie или пользователь с JWT, When GET, Then доступ разрешён
(policy
CatalogAccess); отсутствие обоих → 401 UNAUTHORIZED.
- Given цена изменилась в Talent/
chessboard, When GET после ингеста, Then новая точка видна с
задержкой ≤ freshness ленты (≤ 5 мин).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы