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:
@@ -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, а не в приложении.
|
||||
Reference in New Issue
Block a user