245 lines
20 KiB
Markdown
245 lines
20 KiB
Markdown
# 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, не учитывается (RUB-only, WealthLedger/main#55).
|
||
- Учитываются только операции с `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. `UNVALUED_SECURITIES_TRANSFER`.
|
||
5. `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 и прототипе текст обновлён: вывод
|
||
бумаг без оценки так же искажает расчёт.
|