Второй источник выплат: 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).
12 KiB
Контракт 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).
{
"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.
{
"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).
{
"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
// 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).
{
"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
{"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; можно повторять).
{
"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}.
{"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
{
"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 (необязательно).
{
"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
Открытые лоты с датой, после которой продажа попадает под ЛДВ:
{
"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
Новые экраны и маршруты:
/income— календарь выплат (список по месяцам,basisвиден на каждой строке), вкладка «История» с помесячной таблицей и графиком, вкладка «Прогноз» на 12 мес./rebalance— целевые веса (редактирование) и рекомендации по текущему портфелю./goals— список целей с прогрессом; карточка цели с прогнозной датой./tax— сводка по году и список лотов с датой ЛДВ.- Бенчмарки — не отдельный экран, а блок сравнения на существующем «Портфель».
После любого изменения целей, весов или бенчмарков клиент инвалидирует соответствующие провайдеры: цифры пересчитываются на сервере при следующем refresh, а не в приложении.