spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) (#85)
This commit was merged in pull request #85.
This commit is contained in:
+247
@@ -0,0 +1,247 @@
|
||||
# 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 away from zero, как общий decimal helper); доли (`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
|
||||
в пределах 5 минут — только кандидат. Пока нет подтверждения связи,
|
||||
оба счёта исключаются из расчётной части `all` с причиной
|
||||
`UNCONFIRMED_TRANSFER`. Без кандидата операция остаётся внешним потоком.
|
||||
- **Бумаги.** `OUTPUT_SECURITIES` / `TRANS_*` на счёте A и
|
||||
`INPUT_SECURITIES` / `TRANS_*` на счёте B с тем же инструментом и
|
||||
количеством в тот же день — только кандидат; оба счёта исключаются.
|
||||
Для одиночного ввода или вывода применяется `UNVALUED_SECURITIES_TRANSFER`.
|
||||
|
||||
В текущем контракте T-Bank нет признака, подтверждающего пару. Полностью
|
||||
корректное включение счетов с переводами в `all` отложено до появления
|
||||
подтверждённой связи; не вычислять процент на предположении.
|
||||
|
||||
### Причины недоступности
|
||||
|
||||
Причины проверяются по каждому счёту в таком порядке; используется первая
|
||||
сработавшая:
|
||||
|
||||
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`.
|
||||
6. `UNCONFIRMED_TRANSFER` (только `all`, когда есть кандидат на внутренний
|
||||
перевод без доказанной связи).
|
||||
|
||||
Как причины попадают в ответ:
|
||||
|
||||
- Для отдельного счёта любая причина даёт `data: null` и одну запись в
|
||||
`meta.unavailable`.
|
||||
- В `all` исключённые счета перечисляются в `excludedAccounts`, а
|
||||
`completeness = incomplete`. Если не осталось ни одного счёта, ответ
|
||||
`data: null`, а `meta.unavailable` содержит все счета.
|
||||
- `NO_SOLUTION` и `AMBIGUOUS_RETURN` бывают только в `return`
|
||||
(пользователя или индекса) и не
|
||||
исключает счёт.
|
||||
- Если даты последних полных оценок оставшихся счетов `all` в Москве
|
||||
различаются, сводка целиком `data: null` с причиной
|
||||
`ACCOUNT_VALUATION_DATES_DIFFER` в `meta.unavailable` для каждого
|
||||
затронутого счёта: суммы разных дат не складываются.
|
||||
- `INDEX_HISTORY_UNAVAILABLE` бывает только в `benchmark` / `reference`.
|
||||
|
||||
### Расчёт
|
||||
|
||||
- **Период.** `from` — дата первого внешнего потока по Москве (решение
|
||||
Change Owner: не дата открытия счёта). Полнота истории по-прежнему
|
||||
проверяется от `historyFrom`. `to` — дата по Москве `fetchedAt`; для `all` она общая
|
||||
только когда все включённые счета оценены в один московский день.
|
||||
`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`. Если решений более одного — `AMBIGUOUS_RETURN`,
|
||||
произвольный корень не выбирается. Смена знака на концах диапазона
|
||||
не доказывает единственность решения.
|
||||
- Нет потоков → `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` либо `AMBIGUOUS_RETURN` | Нет |
|
||||
| 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 и прототипе текст обновлён: вывод
|
||||
бумаг без оценки так же искажает расчёт.
|
||||
Reference in New Issue
Block a user