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