Files
codex-bot b6a9ff2d10
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
spec: require RUB-only behavior (WealthLedger/main#55) (#86)
2026-11-11 09:20:25 +03:00

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