Files
specs/openspec/changes/2026-11-07-investment-performance-vs-index/system-analysis/analysis.md
T
ksv741 5625c52b29
Conventional commits / commits (pull_request) Successful in 15s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 12s
spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54)
2026-11-07 20:09:57 +03:00

231 lines
18 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.
# 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 и прототипе текст обновлён: вывод
бумаг без оценки так же искажает расчёт.