# Контракт 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:` | `portfolio:`; по умолчанию `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, а не в приложении.