Перейти к основному содержимому

Расчётные модели: 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

ПолеТипОбязательностьОграниченияИсточникОписание
BasePricedecimalДа>= 0, рубобъект недвижимости (chessboard, эффективная цена с учётом каталожных оверрайдов) или тело admin-previewЦена покупки объекта недвижимости — знаменатель всех ROI.
AreadecimalДа> 0, м²объект недвижимости / admin-previewОбщая площадь: все per-м²-величины умножаются на неё.
PricePerMeterdecimalДа> 0, руб/м²объект недвижимости / admin-previewПрайсовая цена м²; старт рыночной цены = PricePerMeter × (1 + MarketPremium).
CoefficientsInvestorCoefficientsДасм. нижеразрешение 3 уровнейЭффективные коэффициенты.
MonthOverridesIReadOnlyList<MonthOverrideValues>Да (может быть пуст)ключ (Year,Month) уникаленproperty-object-type-month-override.md по GroupId объекта недвижимости / тело admin-previewПомесячные правки.
EntityEntityKindДаLegal/Individual; дефолт Legalquery-параметр entityТип субъекта — tax-entity-params.md.
TaxTaxRegimeДаUsn6/Usn15/Osno; дефолт Usn6query-параметр taxНалоговый режим.
AnchorTimeDateOnlyДапервый день месяцасервер: первый день текущего месяца (yyyy-MM-01)Точка отсчёта серии (monthIndex = 0).
DeliveryTimeDateOnly?Нетпервый день месяца или nullпарсинг срока сдачи корпуса (chessboard); admin-preview → nullМесяц ввода в эксплуатацию; до него аренда/EBITDA = 0. Правила парсинга — ниже.
MortgageAnnualRatedecimalДа> 0, процент годовых (напр. 21)PurchaseProgramConfig (crm/booking); admin-preview — константа 21Ставка для CreditPayment.
MortgageMonthsintДа> 0 (напр. 240)там же; admin-preview — константа 240Срок ипотеки в месяцах.

InvestorCoefficients (эффективные коэффициенты)

Девять полей — MarketPremium, PriceGrowthMonthly, DailyRentM2, RentGrowthMonthly, ExpenseShare, AppreciationTaxMonthly, EbitdaTaxMonthly, Occupancy, HorizonMonths; семантика/единицы — economic-model-config.md. AppreciationTaxMonthly/EbitdaTaxMonthly в формулы серий не входят (легаси, см. INV-CALC-5) — ставка определяется режимом/оверрайдом месяца.

Разрешение эффективных коэффициентов

Для каждого из девяти полей независимо берётся первое валидное значение цепочки:

  1. PropertyObjectTypeConfig по GroupId объекта недвижимости — если строка существует и поле not null и конечное;
  2. EconomicModelConfig (singleton) — если поле конечное;
  3. 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 — парсинг строки срока сдачи корпуса: ISO yyyy-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 (защита сериализации).

ПолеТипФормула / источникОписание
TimeDateOnlyAnchorTime + i месяцев (первое число)Месяц строки (yyyy-MM-01).
Yearintиз TimeГод.
Monthintиз Time, 1–12Месяц.
MonthIndexintiСмещение от старта серии, 0-based.
MarketPriceM2decimalround2(marketM2(i))Рыночная цена м², руб.
ObjectPricedecimalround2(marketM2(i) × Area)Рыночная стоимость объекта недвижимости, руб.
IncomeFromGrowthdecimalround2(gainM2(i) × Area)Доход от роста стоимости (до налога), руб.
RentMonthdecimalround2(monthRevenueM2(i) × Area)Выручка аренды за месяц, руб.
Expensesdecimalround2(monthRevenueM2(i) × expShare(i) × Area)Операционные расходы месяца, руб.
Ebitdadecimalround2(netEbitdaM2(i) × Area)Накопленный EBITDA после налога, руб.
CumulativeEbitdadecimalround2(netEbitdaM2(i) × Area)Дублирует Ebitda (EBITDA уже накопительный — считается от cumRevenueM2); поле сохранено для совместимости контракта.
CumulativeRevenuedecimalround2(cumRevenueM2(i) × Area)Накопленная выручка аренды, руб.
CumulativeExpensesdecimalround2(cumRevenueM2(i) × expShare(i) × Area)Накопленные расходы (по доле текущего месяца), руб.
AppreciationTaxdecimalround2(apprTaxM2(i) × Area)Налог на прирост стоимости, руб.
EbitdaTaxdecimalround2(ebitdaTaxM2(i) × Area)Налог на EBITDA, руб.
GrossProfitdecimalround2((growthIncomeM2(i) + netEbitdaM2(i)) × Area)Валовая прибыль (рост после налога + EBITDA после налога), руб.
RoiGrossdecimalBasePrice == 0 ? 0 : GrossProfit(до округления) / BasePriceROI валовой прибыли, доля (не процент).
Occupancydecimalocc(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.

ПолеТипФормулаОписание
ForecastDatestring"{МесяцРус} {Год}" из sel.Time (напр. «Март 2028»)Подпись прогнозной даты.
ForecastPricedecimalround(sel.ObjectPrice)Прогнозная стоимость объекта недвижимости, руб.
ForecastPricePerMeterdecimalround(sel.MarketPriceM2)Прогнозная цена м², руб.
GrossProfitdecimalround(sel.GrossProfit)Валовая прибыль, руб.
RoidecimaltoPercent(sel.RoiGross)ROI валовой прибыли, %.
RoiPerYeardecimaltoPercent(Σ InvestorMonth[1..12].RoiGross)Годовой ROI (сумма месячных долей первого года; одна величина для всех срезов).
GrowthIncomedecimalround(sel.IncomeFromGrowth − sel.AppreciationTax)Доход от роста после налога, руб.
RoiGrowthdecimalBasePrice == 0 ? 0 : toPercent((sel.IncomeFromGrowth − sel.AppreciationTax) / BasePrice)ROI дохода от роста, %.
RevenueGrowthdecimalround(sel.IncomeFromGrowth)Прирост стоимости до налога, руб.
ProfitTaxdecimalround(sel.AppreciationTax)Налог на прирост, руб.
TaxRatePercentdecimalround(taxRate × 100)Ставка режима, % (целое).
Ebitdadecimalround(sel.Ebitda)EBITDA после налога, руб.
RoiEbitdadecimalBasePrice == 0 ? 0 : toPercent(sel.Ebitda / BasePrice)ROI EBITDA, %.
RentAveragedecimalround(sel.RentMonth)Аренда выбранного месяца, руб.
Occupancydecimalround(sel.Occupancy × 100)Заполняемость, % (целое).
TotalRevenuedecimalround(sel.CumulativeRevenue)Накопленная выручка, руб.
Expensesdecimalround(sel.CumulativeExpenses)Накопленные расходы, руб.
CreditPaymentdecimalround(MortgageCalculation.MonthlyPayment)Платёж по ипотеке (одна величина для всех срезов).
Growth12mPercentdecimaltoPercent(growth12m(i)): если есть месяц i+12marketM2(i+12)/marketM2(i) − 1; иначе если есть i−12marketM2(i)/marketM2(i−12) − 1; иначе 0Рост цены м² за 12 месяцев (вперёд, у края серии — назад), %.
GrowthTotalPercentdecimalbase.MarketPriceM2 == 0 ? 0 : toPercent(sel.MarketPriceM2 / base.MarketPriceM2 − 1)Совокупный рост от старта серии, %.
MonthsElapsedintsel.MonthIndexПрошло месяцев от старта.

Граничные случаи серии:

  • HorizonMonths < 12: RoiPerYear суммирует RoiGross только по фактически существующим месяцам 1..min(12, HorizonMonths) (серия короче года — годовой ROI считается по доступному хвосту, не по недостающим 12); Growth12mPercent у таких срезов не находит ни i+12, ни i−120.
  • 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).

ПолеТипФормулаОписание
MonthlyPaymentdecimalS × r × (1+r)^Months / ((1+r)^Months − 1), округление до 2 знаковАннуитетный платёж.
TotalPaymentdecimalMonthlyPayment × MonthsВсего выплат по кредиту.
OverpaymentdecimalTotalPayment − SПереплата.
LoanAmountdecimalSТело кредита.
TotalWithDownPaymentdecimalпервоначальный взнос + TotalPaymentПолная стоимость.

InvestorSeriesResult — контейнер ответа

ПолеТипОписание
MonthsIReadOnlyList<InvestorMonth>HorizonMonths + 1 строк, monthIndex 0..N.
SeriesAnalyticsSeriesТри серии графиков.
MetricsByMonthIReadOnlyList<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 SeriesLengthMonths.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.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы