Расчётные модели: InvestorMonth, AnalyticsSeries, AsideMetrics
Расчётные (вычисляемые, не хранимые) модели серверного расчёта доходности объекта недвижимости.
Назначение
По объекту недвижимости (или произвольным параметрам в admin-preview) сервер строит помесячную серию
InvestorMonth[0..HorizonMonths], три графика AnalyticsSeries (валовая прибыль, EBITDA, стоимость м² —
они же admin growth-chart) и сводку AsideMetrics для каждого месяца (правая панель страницы
/lots/[id]/analytics и мобильный calc-block; readOnly для реф-гостя). Параметры пользователя: тип
субъекта (юр/физ лицо) и налоговый режим (УСН-6/УСН-15/ОСНО) — tax-entity-params.md.
Модели не сохраняются ни в PostgreSQL, ни в ClickHouse: расчёт детерминирован по входу и дёшев (≤ 361 итерация цикла при горизонте 360), кэш не вводится (ADR-0056).
Классификация
| Поле | Значение |
|---|---|
| Контекст | invest-analytics |
| Kind | вычисляемые модели (не сущности, не VO-owned, без таблиц; не персистятся) |
| Source of truth | входы: chessboard (параметры объекта недвижимости), economic-model-config.md / property-object-type-config.md / property-object-type-month-override.md (коэффициенты), price-history-fact.md (историческая часть серии price-per-meter) |
| Tenant isolation | неприменимо (ответ на запрос; вход платформенный) |
Вход расчёта: InvestorCalculationInput
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
BasePrice | decimal | Да | >= 0, руб | объект недвижимости (chessboard, эффективная цена с учётом каталожных оверрайдов) или тело admin-preview | Цена покупки объекта недвижимости — знаменатель всех ROI. |
Area | decimal | Да | > 0, м² | объект недвижимости / admin-preview | Общая площадь: все per-м²-величины умножаются на неё. |
PricePerMeter | decimal | Да | > 0, руб/м² | объект недвижимости / admin-preview | Прайсовая цена м²; старт рыночной цены = PricePerMeter × (1 + MarketPremium). |
Coefficients | InvestorCoefficients | Да | см. ниже | разрешение 3 уровней | Эффективные коэффициенты. |
MonthOverrides | IReadOnlyList<MonthOverrideValues> | Да (может быть пуст) | ключ (Year,Month) уникален | property-object-type-month-override.md по GroupId объекта недвижимости / тело admin-preview | Помесячные правки. |
Entity | EntityKind | Да | Legal/Individual; дефолт Legal | query-параметр entity | Тип субъекта — tax-entity-params.md. |
Tax | TaxRegime | Да | Usn6/Usn15/Osno; дефолт Usn6 | query-параметр tax | Налоговый режим. |
AnchorTime | DateOnly | Да | первый день месяца | сервер: первый день текущего месяца (yyyy-MM-01) | Точка отсчёта серии (monthIndex = 0). |
DeliveryTime | DateOnly? | Нет | первый день месяца или null | парсинг срока сдачи корпуса (chessboard); admin-preview → null | Месяц ввода в эксплуатацию; до него аренда/EBITDA = 0. Правила парсинга — ниже. |
MortgageAnnualRate | decimal | Да | > 0, процент годовых (напр. 21) | PurchaseProgramConfig (crm/booking); admin-preview — константа 21 | Ставка для CreditPayment. |
MortgageMonths | int | Да | > 0 (напр. 240) | там же; admin-preview — константа 240 | Срок ипотеки в месяцах. |
InvestorCoefficients (эффективные коэффициенты)
Девять полей — MarketPremium, PriceGrowthMonthly, DailyRentM2, RentGrowthMonthly, ExpenseShare,
AppreciationTaxMonthly, EbitdaTaxMonthly, Occupancy, HorizonMonths; семантика/единицы —
economic-model-config.md. AppreciationTaxMonthly/EbitdaTaxMonthly в
формулы серий не входят (легаси, см. INV-CALC-5) — ставка определяется режимом/оверрайдом месяца.
Разрешение эффективных коэффициентов
Для каждого из девяти полей независимо берётся первое валидное значение цепочки:
PropertyObjectTypeConfigпоGroupIdобъекта недвижимости — если строка существует и поле not null и конечное;EconomicModelConfig(singleton) — если поле конечное;- hard-coded дефолт (таблица дефолтов в economic-model-config.md).
«Конечное» — защита от NaN/Infinity; при типах numeric шаги 1–2 всегда валидны при not null, правило
сохраняется как guard. MonthOverrides в разрешение уровней не входят — применяются поточечно внутри цикла
(см. формулы).
MonthOverrideValues (снапшот строки оверрайда в расчёте)
Проекция полей property-object-type-month-override.md один-в-один:
Year, Month, MarketPriceM2?, PriceGrowthMonthly?, DailyRentM2?, RentGrowthMonthly?, Occupancy?,
ExpenseShare?, Ebitda?, AppreciationTaxMonthly?, EbitdaTaxMonthly? — дельты нет.
Правила дат
AnchorTime= первый день текущего месяца сервера.DeliveryTime— парсинг строки срока сдачи корпуса: ISOyyyy-MM…→yyyy-MM-01; «N кв. YYYY» (арабские/римские) → последний месяц квартала (3/6/9/12); название месяца по-русски + год → этот месяц; только год →YYYY-12-01; не распарсилось →null.- Месяц эксплуатации:
inOperation(i) = DeliveryTime == null ИЛИ monthTime(i) >= DeliveryTime.
InvestorMonth — помесячная строка серии
Обозначения промежуточных per-м²-величин месяца i (0-based, i = 0..HorizonMonths):
initialMarketM2 = PricePerMeter × (1 + MarketPremium)— стартовая рыночная цена м²;ovr(i)— оверрайд месяца (Year,Month) либо отсутствует;a ?? b— «если not null, иначе b»;positiveOr(x, y)— «x, если x not null и x > 0, иначе y»;marketM2(0) = positiveOr(ovr(0).MarketPriceM2, initialMarketM2);marketM2(i>0) = positiveOr(ovr(i).MarketPriceM2, marketM2(i−1) × (1 + (ovr(i).PriceGrowthMonthly ?? PriceGrowthMonthly)));dailyRent(0) = positiveOr(ovr(0).DailyRentM2, DailyRentM2);dailyRent(i>0) = positiveOr(ovr(i).DailyRentM2, dailyRent(i−1) × (1 + (ovr(i).RentGrowthMonthly ?? RentGrowthMonthly)));gainM2(i) = marketM2(i) − initialMarketM2— прирост стоимости м²;apprTaxRate(i) = ovr(i).AppreciationTaxMonthly != null ? ovr(i).AppreciationTaxMonthly × 12 : regimeTaxRate— гдеregimeTaxRateиз tax-entity-params.md;apprTaxM2(i) = gainM2(i) × apprTaxRate(i);growthIncomeM2(i) = gainM2(i) − apprTaxM2(i);occ(i) = ovr(i).Occupancy ?? Occupancy;expShare(i) = ovr(i).ExpenseShare ?? ExpenseShare;monthRevenueM2(i) = inOperation(i) ? dailyRent(i) × daysInMonth(i) × occ(i) : 0— выручка аренды м²/мес;cumRevenueM2(i) = Σ(monthRevenueM2(0..i))— накопленная выручка;ebitdaM2(i) = inOperation(i) ? positiveOr(ovr(i).Ebitda, cumRevenueM2(i) × (1 − expShare(i))) : 0— накопленный EBITDA/м² до налога;ebitdaTaxRate(i) = ovr(i).EbitdaTaxMonthly != null ? ovr(i).EbitdaTaxMonthly × 12 : regimeTaxRate;ebitdaTaxM2(i) = ebitdaM2(i) × ebitdaTaxRate(i);netEbitdaM2(i) = ebitdaM2(i) − ebitdaTaxM2(i).
Округление: round2(x) — до 2 знаков; любое не-конечное число → 0 (защита сериализации).
| Поле | Тип | Формула / источник | Описание |
|---|---|---|---|
Time | DateOnly | AnchorTime + i месяцев (первое число) | Месяц строки (yyyy-MM-01). |
Year | int | из Time | Год. |
Month | int | из Time, 1–12 | Месяц. |
MonthIndex | int | i | Смещение от старта серии, 0-based. |
MarketPriceM2 | decimal | round2(marketM2(i)) | Рыночная цена м², руб. |
ObjectPrice | decimal | round2(marketM2(i) × Area) | Рыночная стоимость объекта недвижимости, руб. |
IncomeFromGrowth | decimal | round2(gainM2(i) × Area) | Доход от роста стоимости (до налога), руб. |
RentMonth | decimal | round2(monthRevenueM2(i) × Area) | Выручка аренды за месяц, руб. |
Expenses | decimal | round2(monthRevenueM2(i) × expShare(i) × Area) | Операционные расходы месяца, руб. |
Ebitda | decimal | round2(netEbitdaM2(i) × Area) | Накопленный EBITDA после налога, руб. |
CumulativeEbitda | decimal | round2(netEbitdaM2(i) × Area) | Дублирует Ebitda (EBITDA уже накопительный — считается от cumRevenueM2); поле сохранено для совместимости контракта. |
CumulativeRevenue | decimal | round2(cumRevenueM2(i) × Area) | Накопленная выручка аренды, руб. |
CumulativeExpenses | decimal | round2(cumRevenueM2(i) × expShare(i) × Area) | Накопленные расходы (по доле текущего месяца), руб. |
AppreciationTax | decimal | round2(apprTaxM2(i) × Area) | Налог на прирост стоимости, руб. |
EbitdaTax | decimal | round2(ebitdaTaxM2(i) × Area) | Налог на EBITDA, руб. |
GrossProfit | decimal | round2((growthIncomeM2(i) + netEbitdaM2(i)) × Area) | Валовая прибыль (рост после налога + EBITDA после налога), руб. |
RoiGross | decimal | BasePrice == 0 ? 0 : GrossProfit(до округления) / BasePrice | ROI валовой прибыли, доля (не процент). |
Occupancy | decimal | occ(i) | Заполняемость месяца, доля (без округления). |
AnalyticsSeries — серии графиков
Контейнер трёх серий точек MetricSeriesPoint { Time: DateOnly, Value: decimal } (JSON-ключи
gross-profit, ebitda, price-per-meter — и график объекта недвижимости, и admin growth-chart):
| Серия | Формула точки | Состав |
|---|---|---|
GrossProfit (gross-profit) | round2(InvestorMonth.GrossProfit / Area) — валовая прибыль на м² | все месяцы серии |
Ebitda (ebitda) | round2(InvestorMonth.Ebitda / Area) — EBITDA на м² | только месяцы с Ebitda > 0 (до сдачи корпуса точек нет) |
PricePerMeter (price-per-meter) | историческая часть + round2(InvestorMonth.MarketPriceM2) прогнозной части | история до AnchorTime + прогноз AnchorTime..горизонт |
Историческая часть price-per-meter:
- Основной источник — реальные точки из price-history-fact.md: агрегация
NewPricePerMeterпо объекту недвижимости с грануляцией месяц (argMax(NewPricePerMeter, EventTime)внутри месяца), диапазон — от первого факта доAnchorTime. - Fallback (пока по объекту недвижимости меньше 2 фактов): синтетика — годовые точки
01-01с 2022 по прошлый год, экстраполяция назад отmarketM2(0)факторомannualFactor = marketM2(12)/marketM2(0):value(год g) = marketM2(0) / annualFactor^(прошлыйГод − g); пустой список, если серия короче 13 месяцев или значения ≤ 0. Использование fallback помечается флагомIsSyntheticHistoryв ответе — часть контракта метода.
AsideMetrics — сводка выбранного месяца
Считается для каждого месяца серии (MetricsByMonth[i]) — фронт листает срез слайдером без
повторных запросов. Обозначения: sel = InvestorMonth[i] (выбранный), base = InvestorMonth[0];
taxRate = regimeTaxRate (tax-entity-params.md); round(x) — до целого;
toPercent(x) — доля → процент с 1 знаком (round(x × 1000)/10); не-конечное → 0.
| Поле | Тип | Формула | Описание |
|---|---|---|---|
ForecastDate | string | "{МесяцРус} {Год}" из sel.Time (напр. «Март 2028») | Подпись прогнозной даты. |
ForecastPrice | decimal | round(sel.ObjectPrice) | Прогнозная стоимость объекта недвижимости, руб. |
ForecastPricePerMeter | decimal | round(sel.MarketPriceM2) | Прогнозная цена м², руб. |
GrossProfit | decimal | round(sel.GrossProfit) | Валовая прибыль, руб. |
Roi | decimal | toPercent(sel.RoiGross) | ROI валовой прибыли, %. |
RoiPerYear | decimal | toPercent(Σ InvestorMonth[1..12].RoiGross) | Годовой ROI (сумма месячных долей первого года; одна величина для всех срезов). |
GrowthIncome | decimal | round(sel.IncomeFromGrowth − sel.AppreciationTax) | Доход от роста после налога, руб. |
RoiGrowth | decimal | BasePrice == 0 ? 0 : toPercent((sel.IncomeFromGrowth − sel.AppreciationTax) / BasePrice) | ROI дохода от роста, %. |
RevenueGrowth | decimal | round(sel.IncomeFromGrowth) | Прирост стоимости до налога, руб. |
ProfitTax | decimal | round(sel.AppreciationTax) | Налог на прирост, руб. |
TaxRatePercent | decimal | round(taxRate × 100) | Ставка режима, % (целое). |
Ebitda | decimal | round(sel.Ebitda) | EBITDA после налога, руб. |
RoiEbitda | decimal | BasePrice == 0 ? 0 : toPercent(sel.Ebitda / BasePrice) | ROI EBITDA, %. |
RentAverage | decimal | round(sel.RentMonth) | Аренда выбранного месяца, руб. |
Occupancy | decimal | round(sel.Occupancy × 100) | Заполняемость, % (целое). |
TotalRevenue | decimal | round(sel.CumulativeRevenue) | Накопленная выручка, руб. |
Expenses | decimal | round(sel.CumulativeExpenses) | Накопленные расходы, руб. |
CreditPayment | decimal | round(MortgageCalculation.MonthlyPayment) | Платёж по ипотеке (одна величина для всех срезов). |
Growth12mPercent | decimal | toPercent(growth12m(i)): если есть месяц i+12 → marketM2(i+12)/marketM2(i) − 1; иначе если есть i−12 → marketM2(i)/marketM2(i−12) − 1; иначе 0 | Рост цены м² за 12 месяцев (вперёд, у края серии — назад), %. |
GrowthTotalPercent | decimal | base.MarketPriceM2 == 0 ? 0 : toPercent(sel.MarketPriceM2 / base.MarketPriceM2 − 1) | Совокупный рост от старта серии, %. |
MonthsElapsed | int | sel.MonthIndex | Прошло месяцев от старта. |
Граничные случаи серии:
HorizonMonths < 12:RoiPerYearсуммируетRoiGrossтолько по фактически существующим месяцам1..min(12, HorizonMonths)(серия короче года — годовой ROI считается по доступному хвосту, не по недостающим 12);Growth12mPercentу таких срезов не находит ниi+12, ниi−12→0.HorizonMonths == 0(вырожденный, но валидный горизонт — INV-MC2): серия из одной строкиInvestorMonth[0],RoiPerYear = 0, накопительные величины равны месяцу 0.- Историческая часть
price-per-meter: синтетический fallback требуетmarketM2(12)(факторannualFactor), поэтому приHorizonMonths < 12синтетика не строится — историческая часть пуста; флагIsSyntheticHistoryотражает, что был выбран fallback-путь (реальных фактов< 2), даже если его результат пуст.
MortgageCalculation (аннуитет)
Вход: TotalPrice = BasePrice, InitialPaymentPercent = 0 (в этом расчёте), Months = MortgageMonths,
AnnualRate = MortgageAnnualRate. r = AnnualRate / 100 / 12; S = TotalPrice × (1 − InitialPaymentPercent/100).
| Поле | Тип | Формула | Описание |
|---|---|---|---|
MonthlyPayment | decimal | S × r × (1+r)^Months / ((1+r)^Months − 1), округление до 2 знаков | Аннуитетный платёж. |
TotalPayment | decimal | MonthlyPayment × Months | Всего выплат по кредиту. |
Overpayment | decimal | TotalPayment − S | Переплата. |
LoanAmount | decimal | S | Тело кредита. |
TotalWithDownPayment | decimal | первоначальный взнос + TotalPayment | Полная стоимость. |
InvestorSeriesResult — контейнер ответа
| Поле | Тип | Описание |
|---|---|---|
Months | IReadOnlyList<InvestorMonth> | HorizonMonths + 1 строк, monthIndex 0..N. |
Series | AnalyticsSeries | Три серии графиков. |
MetricsByMonth | IReadOnlyList<AsideMetrics> | Сводка для каждого месяца (той же длины, что Months). |
Диаграмма расчёта
Инварианты расчёта
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-CALC-1 FiniteOutput | Ни одно числовое поле результата не NaN/Infinity: делители проверяются (BasePrice == 0 → ROI = 0, Area == 0 → per-м² = 0, marketM2 == 0 → рост = 0), финальный проход обнуляет не-конечные | функции расчёта + финальный guard | — (никогда не ошибка, всегда 0) |
| INV-CALC-2 PositiveOverridesOnly | Абсолютные оверрайды (MarketPriceM2, DailyRentM2, Ebitda) применяются только при значении > 0 | правило positiveOr в цикле | — |
| INV-CALC-3 NoRentBeforeDelivery | До DeliveryTime: RentMonth = Expenses = Ebitda = 0, точки серии ebitda отсутствуют | флаг inOperation(i) | — |
| INV-CALC-4 SeriesLength | Months.Count == MetricsByMonth.Count == HorizonMonths + 1 | сборка результата | инфраструктурная ошибка (не бизнес) |
| INV-CALC-5 RegimeTaxWins | Ставки налогов: оверрайд месяца (× 12) → ставка режима; поля AppreciationTaxMonthly/EbitdaTaxMonthly уровней model/type в расчёт не входят | формулы apprTaxRate/ebitdaTaxRate | — |
| INV-CALC-6 Determinism | Результат детерминирован по входу (единственная внешняя величина — текущий месяц AnchorTime) | чистые функции без I/O внутри цикла | — |
События
События не публикуются: расчёт — чтение (read-only), мутаций нет.
Совместимость контракта
- Ключи ответа (camelCase поля, серии
gross-profit/ebitda/price-per-meter) — часть публичного контракта, сохраняются дословно. - Additive-изменения (новые поля
AsideMetrics) — допустимы; удаление/переименование — breaking, через версию метода. ПолеCumulativeEbitda(дубльEbitda) не удалять до мажорной версии. - Unknown enum на входе (
entity/tax) → ошибка валидацииINVEST_ANALYTICS_INVALID_TAX_PARAMS(не молчаливый дефолт) — см. tax-entity-params.md.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Налоговые параметры расчёта: EntityKind, TaxRegime, Сущность / агрегат: EconomicModelConfig, Сущность / агрегат: PropertyObjectTypeConfig, Сущность: PropertyObjectTypeMonthOverride, PropertyObjectPriceHistoryFact (ClickHouse)
- API (7): GET /api/admin/v1/property-object-types/{groupId}/month-overrides — Помесячные оверрайды типа объекта недвижимости (admin), GET /api/v1/property-objects/{propertyObjectId}/investor-series — Серии доходности объекта недвижимости, GET /api/v1/property-objects/{propertyObjectId}/price-history — История цены объекта недвижимости (график роста), POST /api/admin/v1/investor-preview — Расчёт серий доходности по произвольным параметрам (admin preview / growth-chart), POST /api/v1/property-objects/{propertyObjectId}/analytics-report — PDF-отчёт «Скачать расчёт» (реализация отложена), PUT /api/admin/v1/model-config — Обновление платформенной экономической модели (admin), PUT /api/admin/v1/property-object-types/{groupId}/month-overrides — Replace-all помесячных оверрайдов типа объекта недвижимости (admin)
- Use Cases (4): UC-PRC-002. Расчёт доходности объекта недвижимости с выбором субъекта (юр/физ) и налогового режима, UC-PRC-003. Admin: настройка экономической модели платформы + investor-preview, UC-PRC-004. Admin: настройка типа объекта недвижимости и помесячных оверрайдов, UC-PRC-005. Скачивание PDF-отчёта «Скачать расчёт» (контракт; реализация отложена)
- События (1): Event Contract: pin.invest-analytics.property-object-metrics-updated (PropertyObjectMetricsUpdatedKafkaEvent)
- Консюмеры (1): Consumer: ChessboardPropertyObjectPriceChangedConsumer
- index.md (1): InvestAnalytics Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- Входные уровни коэффициентов: economic-model-config.md, property-object-type-config.md, property-object-type-month-override.md.
- Налоги: tax-entity-params.md.
- История цен для серии
price-per-meter: price-history-fact.md. - Операции: ../api/public/get-property-object-investor-series.md (public), ../api/admin/investor-preview.md (admin, произвольные параметры), ../api/developers/create-analytics-report.md (PDF «Скачать расчёт», отложено).
- Параметры ипотеки:
docs/06-services/crm/domain/purchase-program.md(контекстcrm).