Files
fin-tracker/docs/ai/phase4-contract.md
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

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

Новые экраны и маршруты:

  1. /income — календарь выплат (список по месяцам, basis виден на каждой строке), вкладка «История» с помесячной таблицей и графиком, вкладка «Прогноз» на 12 мес.
  2. /rebalance — целевые веса (редактирование) и рекомендации по текущему портфелю.
  3. /goals — список целей с прогрессом; карточка цели с прогнозной датой.
  4. /tax — сводка по году и список лотов с датой ЛДВ.
  5. Бенчмарки — не отдельный экран, а блок сравнения на существующем «Портфель».

После любого изменения целей, весов или бенчмарков клиент инвалидирует соответствующие провайдеры: цифры пересчитываются на сервере при следующем refresh, а не в приложении.