Files
fin-tracker/docs/ai/phase4-contract.md
T
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

284 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракт 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, а не в приложении.