feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4

Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+283
View File
@@ -0,0 +1,283 @@
# Контракт API фазы 4 (доходы, ребалансировка, бенчмарки, цели, налоги)
Единственный источник правды по формам запросов и ответов для бэкенда
(`api/routers/{income,goals,rebalance,tax,benchmarks}.py`, `api/schemas/*`) и для
Flutter-экранов. Пишется параллельно, поэтому имена полей и коды ошибок здесь важнее, чем
красота. Аналог `docs/ai/import-contract.md` для фазы 3.
Общие правила проекта действуют без исключений:
- деньги и количества — `Decimal` в Python и **строки** в JSON (`Money` / `MoneyOpt` из
`api/schemas/common.py`); никаких float. Ставки и доли — тоже строки-Decimal
(`"0.1234"` = 12,34 %), а не проценты и не float;
- даты — ISO-8601, таймстемпы — с таймзоной; ошибки — RFC 7807 (`Problem`);
- **енум `AssetClass` наружу не выставляется**: одно из его значений — `index`, а Dart-енум
не может иметь члена с таким именем. `asset_class`, `dimension`, `bucket`, `kind`,
`basis`, `period`, `status` — везде обычные строки;
- у каждого роута обязателен `name=`, иначе Dart-методы получат нечитаемые имена;
- `scope` — та же строка, что и в остальной аналитике: `all` | `account:<id>` |
`portfolio:<id>`; по умолчанию `all`.
## 1. Доходы — `/income`
### `GET /income/calendar`
Параметры: `scope`, `date_from`, `date_to` (по умолчанию — от сегодня на 12 мес вперёд),
`include_paid` (bool, по умолчанию `false`).
```jsonc
{
"as_of": "2026-09-18",
"currency": "RUB",
"total_expected_rub": "14230.50",
"entries": [
{
"instrument_id": 88,
"ticker": "SBER",
"name": "Сбербанк России",
"kind": "dividend", // dividend | coupon | amortization | repayment
"expected_date": "2026-10-12",
"record_date": "2026-10-09",
"qty": "20",
"per_unit": "34.84",
"amount": "696.80",
"currency": "RUB",
"amount_rub": "696.80", // null, если на дату нет курса
"basis": "announced", // schedule | announced | history | paid
"tax_withheld": null
}
],
"by_basis": {"schedule": "8100.00", "announced": "4130.50", "history": "2000.00"}
}
```
**`basis` обязателен к показу.** Это не служебное поле: `schedule` — арифметика по
опубликованному графику, `announced` — объявленный эмитентом факт, `history`
экстраполяция по последним 24 мес, которая может ошибаться на любую величину. Сложить их в
одно «ожидаемый доход» без разбивки — значит выдать догадку за прогноз.
### `GET /income/history`
Параметры: `scope`, `group` (`month`, по умолчанию), `date_from`, `date_to`, `kind`.
```jsonc
{
"rows": [
{"month": "2026-08-01", "kind": "coupon", "currency": "RUB",
"amount": "1204.11", "amount_rub": "1204.11", "tax_withheld": "156.00",
"payment_count": 3}
],
"totals": {"amount_rub": "24518.30", "tax_withheld_rub": "3187.00"}
}
```
### `GET /income/forecast`
Параметры: `scope`, `months` (1..36, по умолчанию 12).
```jsonc
{
"months": [
{"month": "2026-10-01", "amount_rub": "1830.20",
"by_basis": {"schedule": "1133.40", "announced": "696.80", "history": "0"}}
],
"total_rub": "21960.00",
"annual_yield_on_value": "0.081", // ожидаемый доход / текущая стоимость; null, если нет оценки
"warnings": ["у 3 инструментов нет истории выплат — в прогноз не вошли"]
}
```
## 2. Ребалансировка — `/portfolios/{id}`
### `GET /portfolios/{id}/targets` и `PUT /portfolios/{id}/targets`
```jsonc
// PUT: тело — полный набор по одному измерению, частичное обновление не поддерживается
{
"dimension": "asset_class", // asset_class | sector | country | currency
"targets": [
{"bucket": "share", "target_weight": "0.60", "band": "0.05", "note": null},
{"bucket": "bond", "target_weight": "0.30", "band": "0.05"},
{"bucket": "cash", "target_weight": "0.10", "band": "0.02"}
]
}
```
Ответ — сохранённый набор плюс `"weights_sum": "1.00"`. Сервер **не нормализует** веса:
сумма 0,9 — это ошибка пользователя, а не масштаб. При сумме, отличной от 1 более чем на
0,0001, — `422` с указанием фактической суммы.
### `GET /portfolios/{id}/rebalance`
Параметры: `dimension` (по умолчанию `asset_class`), `cash_available` (необязательно —
переопределяет остаток на счетах для what-if).
```jsonc
{
"portfolio_id": 1,
"dimension": "asset_class",
"as_of": "2026-09-18",
"total_value_rub": "1284300.00",
"cash_available_rub": "48120.87",
"buckets": [
{
"bucket": "share",
"current_value_rub": "812000.00",
"current_weight": "0.632",
"target_weight": "0.60",
"drift": "0.032", // current - target, в долях
"within_band": true, // |drift| <= band ⇒ действий не предлагаем
"delta_value_rub": "-41420.00",
"trades": [
{
"instrument_id": 88, "ticker": "SBER", "name": "Сбербанк России",
"action": "sell", // buy | sell
"suggested_qty": "150", // целые лоты; null, если нет цены
"lot": 10,
"price": "275.89",
"price_currency": "RUB",
"amount_rub": "41383.50",
"blocked_by_cash": false
}
]
}
],
"warnings": ["у 2 инструментов нет цены — в рекомендации не вошли"]
}
```
`suggested_qty`**всегда целые лоты и всегда в пределах доступных денег**. Рекомендация,
которую нельзя исполнить, — не рекомендация. Если денег не хватает, количество урезается и
ставится `blocked_by_cash: true`.
## 3. Бенчмарки — `/benchmarks`
### `GET /benchmarks` / `POST /benchmarks` / `PATCH /benchmarks/{id}` / `DELETE`
```jsonc
{"id": 1, "code": "MCFTR", "name": "MOEX Total Return", "kind": "total_return",
"source": "moex", "currency": "RUB", "is_default": true, "is_active": true,
"instrument_id": 412, "history_from": "2025-01-03", "history_to": "2026-09-17"}
```
### `GET /analytics/benchmarks`
Параметры: `scope`, `period` (`1m 3m 6m ytd 1y 3y all`; можно повторять).
```jsonc
{
"rows": [
{
"period": "1y",
"date_from": "2025-09-18", "date_to": "2026-09-18",
"portfolio_twr": "0.184",
"portfolio_twr_annualized": "0.184",
"portfolio_days_skipped": 3,
"benchmarks": [
{"benchmark_id": 1, "code": "MCFTR", "kind": "total_return",
"twr": "0.121", "twr_annualized": "0.121", "days_skipped": 0,
"excess": "0.063"} // portfolio_twr - benchmark twr
]
}
]
}
```
**Сетка дат общая.** Бенчмарк, посчитанный по другому набору дней, — не сравнение; поэтому
`days_skipped` отдаётся с обеих сторон, и если он ненулевой, клиент обязан это показать.
Сравнивать портфель с ценовым индексом (`kind = "price"`) без пометки нельзя: IMOEX не
учитывает дивиденды и систематически занижает результат держателя.
## 4. Цели — `/goals`
CRUD: `GET /goals`, `POST /goals`, `PATCH /goals/{id}`, `DELETE /goals/{id}`.
```jsonc
{"id": 3, "name": "Подушка", "scope": "account:12", "target_amount": "1000000",
"currency": "RUB", "target_date": "2028-01-01", "monthly_contribution": "30000",
"note": null, "archived": false}
```
### `GET /goals/{id}/progress`
```jsonc
{
"goal_id": 3,
"as_of": "2026-09-18",
"current_value_rub": "412800.00",
"target_amount_rub": "1000000.00",
"progress": "0.4128",
"projected_date": "2027-11-14", // null, если тренд не приводит к цели
"basis": "xirr", // xirr | contribution | none
"assumed_rate": "0.142",
"monthly_needed_rub": "42300.00", // сколько нужно докладывать, чтобы успеть к target_date
"on_track": false
}
```
`projected_date = null` — честный ответ «при текущем тренде цель не достигается». Дальняя
дата вместо null запрещена: она выглядит как ответ, не являясь им.
## 5. Налоги — `/tax`
### `GET /tax?year=2026`
Параметры: `year` (по умолчанию текущий), `account_id` (необязательно).
```jsonc
{
"year": 2026,
"estimated": true, // ВСЕГДА true; авторитет — справка брокера
"tax_rate": "0.13",
"accounts": [
{
"account_id": 12, "account_name": "ИИС Сбер",
"dividends_gross_rub": "12400.00",
"coupons_gross_rub": "8100.00",
"tax_withheld_rub": "2665.00",
"realized_gain_rub": "31200.00",
"realized_loss_rub": "-4100.00",
"ldv_exempt_rub": "12000.00",
"taxable_base_rub": "15100.00",
"estimated_tax_rub": "1963.00"
}
],
"totals": { /* те же поля, суммарно */ },
"disclaimer": "Оценка. Налоговый агент — брокер; сверяйтесь с его справкой."
}
```
### `GET /tax/lots?year=2026`
Открытые лоты с датой, после которой продажа попадает под ЛДВ:
```jsonc
{
"lots": [
{"lot_id": 812, "instrument_id": 88, "ticker": "SBER", "account_id": 12,
"open_date": "2024-03-14", "qty_remaining": "20",
"cost_rub": "4800.00", "market_value_rub": "5517.80",
"unrealized_gain_rub": "717.80",
"ldv_eligible": false, "ldv_date": "2027-03-14", "days_to_ldv": 177,
"tax_if_sold_now_rub": "93.31"}
]
}
```
Это главный практический экран фазы: он показывает цену продажи бумаги **до** трёхлетней
отметки. `ldv_eligible` считается по ст. 219.1 и помечается оценкой — см. открытый вопрос 3
плана (иностранные эмитенты и ИИС-3 сюда не заводим).
## Что видит Flutter
Новые экраны и маршруты:
1. `/income` — календарь выплат (список по месяцам, `basis` виден на каждой строке),
вкладка «История» с помесячной таблицей и графиком, вкладка «Прогноз» на 12 мес.
2. `/rebalance` — целевые веса (редактирование) и рекомендации по текущему портфелю.
3. `/goals` — список целей с прогрессом; карточка цели с прогнозной датой.
4. `/tax` — сводка по году и список лотов с датой ЛДВ.
5. Бенчмарки — не отдельный экран, а блок сравнения на существующем «Портфель».
После любого изменения целей, весов или бенчмарков клиент инвалидирует соответствующие
провайдеры: цифры пересчитываются на сервере при следующем refresh, а не в приложении.