spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54)
Conventional commits / commits (pull_request) Successful in 15s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 12s

This commit is contained in:
2026-11-07 20:09:57 +03:00
parent 1b20067ca7
commit 5625c52b29
13 changed files with 1598 additions and 0 deletions
@@ -0,0 +1,230 @@
# System Analysis
Issue: WealthLedger/main#54
## Requirement Coverage
| Нормативная область | Поток и контракт |
| --- | --- |
| `investment-performance`: результат счёта и `all`, доступ | [performance-read.mmd](sequences/performance-read.mmd), [contract](contracts/investment-performance.openapi.yaml) `Performance`, `PerformanceResource` |
| `investment-performance`: внешние потоки, переводы в `all` | [Маппинг операций](#маппинг-операций-t-bank), [Переводы в all](#переводы-между-счетами-в-all) |
| `investment-performance`: доходность, basis annual/period, нет решения | [Расчёт](#расчёт), `ReturnValue` |
| `investment-performance`: те же деньги в индексе, неторговый день, отставание | [Расчёт](#расчёт), `IndexResult`, `Outperformance` |
| `investment-performance`: справочный индекс | `reference`, `SeriesPoint.referenceValue` |
| `investment-performance`: недоступный и неполный результат | [Причины недоступности](#причины-недоступности), `UnavailableReason`, `excludedAccounts`, `meta.unavailable` |
| `investment-performance`: вкладка и график | `series`, `period.days`, `return.basis`; поведение UI — в Design |
| `market-index-history`: хранение, свежесть, недоступность | [index-refresh.mmd](sequences/index-refresh.mmd), [Индексы MOEX](#индексы-moex) |
## Systems and Responsibilities
- **Frontend**: вкладка «Результат». Делает один GET на страницу счёта или
`all`, сам ничего финансово не рассчитывает. Отличает загрузку,
HTTP-ошибку, `data: null` (недоступно) и `completeness: incomplete`.
Форматирует доли в проценты и п.п., подпись берёт из `return.basis` и
`period.days`.
- **Backend**: единственный владелец расчёта. Проверяет доступ, отбирает
счета для расчёта, выделяет внешние потоки из сохранённых операций, берёт
последнюю полную оценку Portfolio, считает XIRR и теневой портфель, хранит и
догружает историю индексов.
- **Сохранённые данные T-Bank** (существующая синхронизация): операции,
граница истории и покрытие счёта, последний Portfolio. Новых вызовов T-Bank
нет.
- **MOEX ISS**: публичный источник дневных закрытий MCFTRR и IMOEX.
Вызывается только backend.
## Interaction Flows
- [performance-read.mmd](sequences/performance-read.mmd): чтение результата
со всеми ветками доступа, недоступности и сбоя индекса.
- [index-refresh.mmd](sequences/index-refresh.mmd): догрузка истории индекса
при чтении, пагинация, сбой источника.
Фоновой задачи нет: история индекса догружается при чтении результата.
Однодневная свежесть (требование `market-index-history`) обеспечивается тем,
что каждое чтение проверяет покрытие до `period.to`.
## Contracts
| Producer → Consumer | Операция | Схема | Совместимость |
| --- | --- | --- | --- |
| Backend → Frontend | `GET /api/v1/brokerage-accounts/{accountId}/performance`, `GET /api/v1/brokerage-accounts/all/performance` (HTTP/JSON, сессия как у остальных `/api/v1`) | [investment-performance.openapi.yaml](contracts/investment-performance.openapi.yaml) | Новые маршруты, аддитивно. Enum `UnavailableReason` расширяемый: неизвестное значение клиент показывает как общую недоступность |
| MOEX ISS → Backend | `GET https://iss.moex.com/iss/history/engines/stock/markets/index/securities/{MCFTRR\|IMOEX}.json?from&till&start&iss.meta=off&history.columns=TRADEDATE,CLOSE` | Блок `history` (TRADEDATE `YYYY-MM-DD`, CLOSE число), `history.cursor` (INDEX, TOTAL, PAGESIZE = 100) | Внешний, без версии. Неожиданная форма ответа считается сбоем источника, частичный разбор запрещён |
- HTTP-ошибки — существующий `ProblemDetailsDto` и коды `UNAUTHORIZED`,
`NOT_FOUND`, `TBANK_NOT_CONNECTED`, `TBANK_UNAVAILABLE`,
`INTERNAL_SERVER_ERROR`. Новых problem codes нет.
- Недоступность расчёта не является HTTP-ошибкой: ответ 200, а причина в
`UnavailableReason`. `MOEX_UNAVAILABLE` в HTTP-ответе не возвращается:
сбой индекса превращается в `INDEX_HISTORY_UNAVAILABLE` внутри
`benchmark`/`reference`.
- Числа: деньги — `MoneyDto` (RUB, десятичная строка, округление до копеек
half-even); доли (`Rate`) — десятичная строка до 6 знаков, `0.151` = 15,1 %;
точки `series` — десятичные RUB до копеек.
- Идемпотентность: GET без побочных эффектов, кроме догрузки истории индекса.
Её повтор безопасен (upsert по индексу и торговому дню).
## Data and State Semantics
### Источники правды
| Данные | Владелец | Используемое значение |
| --- | --- | --- |
| Внешние потоки | Сохранённые операции T-Bank (EXECUTED) | `type`, `occurredAt`, `payment` (знак и валюта) |
| Начало истории | Граница истории счёта | `historyBoundary = known` → `historyFrom` |
| Полнота операций | Покрытие операций счёта | `historyState = complete` от `historyFrom` до времени оценки |
| Текущая стоимость | Последний сохранённый Portfolio | `totalsByCurrency[RUB]`, `fetchedAt` |
| Закрытия индексов | Новое локальное хранилище истории индексов | (индекс, торговый день) → точное CLOSE |
### Маппинг операций T-Bank
| Тип `OPERATION_TYPE_*` | Роль |
| --- | --- |
| `INPUT`, `INPUT_ACQUIRING`, `INPUT_SWIFT`, `INP_MULTI` | Внешнее пополнение: `+|payment|` |
| `OUTPUT`, `OUTPUT_ACQUIRING`, `OUTPUT_SWIFT`, `OUT_MULTI` | Внешний вывод: `−|payment|` |
| `INPUT_SECURITIES`, `OUTPUT_SECURITIES`, `TRANS_IIS_BS`, `TRANS_BS_BS` | Перевод бумаг: у отдельного счёта всегда `UNVALUED_SECURITIES_TRANSFER`; в `all` — см. ниже |
| Сделки, купоны, дивиденды, погашения, амортизация, налоги, комиссии (включая `OUT_FEE`, `OUTPUT_PENALTY`), прочие | Внутренние, в потоки не входят |
- Поток в валюте, отличной от RUB, даёт счёту `NON_RUB_ACTIVITY`.
- `totalsByCurrency` последнего Portfolio с ненулевым значением в другой
валюте тоже даёт `NON_RUB_ACTIVITY`.
- Учитываются только операции с `occurredAt ≤ fetchedAt` Portfolio этого
счёта. Иначе пополнение после оценки исказит результат.
- Дата потока — календарный день `occurredAt` в Europe/Moscow.
### Переводы между счетами в `all`
T-Bank не передаёт в операции счёт-контрагента. Поэтому перевод
определяется парой операций.
- **Деньги.** Вывод на счёте A и пополнение на счёте B (оба входят в
результат) с равной суммой RUB и `occurredAt` в пределах 5 минут.
Пара исключается из потоков. Без пары операция остаётся внешним потоком.
- **Бумаги.** `OUTPUT_SECURITIES` / `TRANS_*` на счёте A и
`INPUT_SECURITIES` / `TRANS_*` на счёте B с тем же инструментом и
количеством в тот же день по Москве. Пара внутренняя. Без пары оба счёта
получают `UNVALUED_SECURITIES_TRANSFER` и исключаются.
Правило парности нужно подтвердить на реальных данных при первом запуске
(см. Assumptions).
### Причины недоступности
Причины проверяются по каждому счёту в таком порядке; используется первая
сработавшая:
1. `HISTORY_START_UNKNOWN`: `historyBoundary = unknown`.
2. `OPERATIONS_HISTORY_INCOMPLETE`: покрытие не `complete`.
3. `PORTFOLIO_VALUATION_INCOMPLETE`: нет Portfolio или у ненулевой позиции
нет вычислимой стоимости (то же правило, что у `portfolio-value-history`).
4. `NON_RUB_ACTIVITY`.
5. `UNVALUED_SECURITIES_TRANSFER`.
Как причины попадают в ответ:
- Для отдельного счёта любая причина даёт `data: null` и одну запись в
`meta.unavailable`.
- В `all` исключённые счета перечисляются в `excludedAccounts`, а
`completeness = incomplete`. Если не осталось ни одного счёта, ответ
`data: null`, а `meta.unavailable` содержит все счета.
- `NO_SOLUTION` бывает только в `return` (пользователя или индекса) и не
исключает счёт.
- `INDEX_HISTORY_UNAVAILABLE` бывает только в `benchmark` / `reference`.
### Расчёт
- **Период.** `from` — `historyFrom` (в `all` — минимальный по счетам
результата). `to` — дата по Москве самого раннего `fetchedAt` среди
счетов результата. `days = to − from` в календарных днях.
`basis = annual` при `days ≥ 365`, иначе `period`.
- **Агрегаты.** `netInvested = Σ потоков`,
`currentValue = Σ totalsByCurrency[RUB]`,
`profit = currentValue − netInvested`. Точная десятичная арифметика,
финальное округление до копеек.
- **XIRR.** Найти r из `Σ CFᵢ / (1 + r)^((dᵢ − d₀) / 365) = 0`, где
пополнение входит как `−`, вывод — как `+`, а `currentValue` — как `+` в
дату `to`. d₀ — дата первого потока. Корень ищется на `(−0.9999, 100]`.
- Нет смены знака или корень не найден с точностью |NPV| ≤ 0,01 ₽ →
`NO_SOLUTION`.
- Нет потоков → `NO_SOLUTION`.
- Для `basis = period` значение равно `(1 + r)^(days / 365) − 1`.
- **Теневой портфель.** Для каждого потока берётся закрытие индекса на дату
потока или на последнюю торговую дату до неё:
`units += CF / close` (масштаб 12 знаков).
`benchmark.value = units × close(asOf)`, где `asOf` — последнее закрытие
`≤ to`. XIRR индекса считается на тех же потоках с `benchmark.value` в
дату `to`.
- **Опережение.**
`outperformance.percentagePoints = return.value − benchmark.return.value`
(одинаковый `basis`), `outperformance.money = currentValue − benchmark.value`.
Если любая из сторон `null`, `outperformance = null`.
- **Series.** Одна точка на торговый день MCFTRR в `[from, to]`:
- `netInvested` — накопленная сумма потоков к этому дню;
- `benchmarkValue` — `units × close` на этот день;
- `referenceValue` — то же для IMOEX, `null` при его недоступности.
Около 250 точек в год, без прореживания.
### Индексы MOEX
- Хранится (индекс, торговый день по Москве) → точное CLOSE, upsert.
Нерабочие дни не хранятся.
- **Свежесть.** Догрузка нужна, если последняя сохранённая дата меньше
`to`, а последняя попытка была больше часа назад. На каждый индекс
одновременно идёт не больше одной догрузки, повторные чтения ждут её
результат. Первая загрузка начинается с `from − 10 дней`, чтобы было
закрытие для потока в выходной.
- **Достаточное покрытие.** Есть закрытие `≤` даты первого потока и
последнее закрытие `≥ to − 10` календарных дней. 10 дней покрывают
новогодние каникулы биржи. Иначе `INDEX_HISTORY_UNAVAILABLE`.
- **Лицензия.** ISS бесплатно отдаёт данные для ознакомления. Приложение
личное, локальное и не распространяет данные. Источник указывается в
«Как считали».
## Error and Recovery Semantics
| Ситуация | Тип | Ответ | Восстановление |
| --- | --- | --- | --- |
| Нет сессии / нет подключения / счёт не найден или недоступен | Доступ | 401 / 503 `TBANK_NOT_CONNECTED` / 404 | Существующие сценарии счёта |
| Причина недоступности счёта | Бизнес | 200, `data: null` или `excludedAccounts` | Исчезает после синхронизации или изменения данных |
| XIRR не сходится | Бизнес | 200, `return.unavailableReason = NO_SOLUTION` | Нет |
| MOEX недоступен, сохранённой истории хватает | Технический | 200, полный результат | Догрузка при следующем чтении (не чаще раза в час) |
| MOEX недоступен, истории не хватает | Технический | 200, `benchmark.unavailableReason = INDEX_HISTORY_UNAVAILABLE`, `series: []` | То же |
| Сбой хранилища или непредвиденная ошибка | Технический | 500 Problem Details | Повтор из UI; другие вкладки не затронуты |
Таймаут MOEX — 10 с на запрос, как у существующего клиента. Повторных
попыток внутри одного чтения нет. Сбой догрузки не удаляет сохранённые
закрытия.
## Non-Functional and Operational Constraints
- **Ответ.** Без догрузки индекса — меньше 1 с для 10 лет истории (~2 500
точек на индекс, до нескольких тысяч операций). С холодной догрузкой —
до `ceil(дней_торгов / 100)` запросов к ISS (~25 на 10 лет на индекс).
- **Наблюдаемость.** События `integration.moex.index_history.succeeded` и
`.failed` с индексом, диапазоном, числом страниц и длительностью. Счёт и
суммы в логи не пишутся.
- **Безопасность.** Значения отдаются только после подтверждения доступа,
как у остальных инвестиционных ресурсов. В MOEX не уходят данные
пользователя.
## Compatibility and Migration
- Новые маршруты и новое хранилище; существующие контракты не меняются.
- Миграция данных не нужна: история индекса заполняется при первом чтении.
- Откат: удалить маршруты и хранилище. Пользовательские данные не
затрагиваются.
## Open Questions / Assumptions
- **Assumption.** Окно 5 минут и правила парности переводов соответствуют
реальным операциям T-Bank. Проверяется на данных пользователя до закрытия
change; при расхождении правило уточняется здесь.
- **Assumption.** Остаток в валюте до 1 единицы (например, копейки USD
после конвертации) всё равно даёт `NON_RUB_ACTIVITY`. Порог не вводится,
пока такой случай не встретится на реальных данных.
- **Elaboration.** Для `market-index-history` уточнены параметры: окно
покрытия 10 дней и интервал повторных попыток 1 час. Новое поведение они
не вводят.
- **Spec alignment.** Формулировка причины уточнена: «ввод или вывод ценных
бумаг без денежной оценки». В delta spec и прототипе текст обновлён: вывод
бумаг без оценки так же искажает расчёт.