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 и прототипе текст обновлён: вывод
|
||||
бумаг без оценки так же искажает расчёт.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WealthLedger investment performance (target contract, WealthLedger/main#54)
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Целевой контракт новых маршрутов. После реализации исполняемым источником
|
||||
становится backend-generated OpenAPI, который MUST быть семантически
|
||||
совместим с этим файлом. Общие схемы `MoneyDto`, `ProblemDetailsDto` и
|
||||
`InvestmentDependencyDto` остаются в опубликованном OpenAPI и здесь только
|
||||
упоминаются.
|
||||
paths:
|
||||
/api/v1/brokerage-accounts/{accountId}/performance:
|
||||
get:
|
||||
operationId: getBrokerageAccountPerformance
|
||||
summary: Результат инвестирования счёта за всё время
|
||||
parameters:
|
||||
- name: accountId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, minLength: 1 }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Performance' }
|
||||
'401': { $ref: '#/components/responses/Problem' }
|
||||
'404': { $ref: '#/components/responses/Problem' }
|
||||
'503': { $ref: '#/components/responses/Problem' }
|
||||
/api/v1/brokerage-accounts/all/performance:
|
||||
get:
|
||||
operationId: getAllBrokerageAccountsPerformance
|
||||
summary: Результат инвестирования режима «Все счета»
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Performance' }
|
||||
'401': { $ref: '#/components/responses/Problem' }
|
||||
'503': { $ref: '#/components/responses/Problem' }
|
||||
components:
|
||||
responses:
|
||||
Problem:
|
||||
description: Ошибка доступа, подключения или технический сбой (существующий Problem Details; коды из PROBLEM_CODES)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema: { $ref: 'openapi.json#/components/schemas/ProblemDetailsDto' }
|
||||
Performance:
|
||||
description: Результат либо явная недоступность. Недоступность расчёта — не ошибка HTTP.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PerformanceResource' }
|
||||
schemas:
|
||||
Decimal:
|
||||
type: string
|
||||
pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$'
|
||||
description: Точное десятичное значение без экспоненты и незначащих нулей
|
||||
Rate:
|
||||
allOf: [{ $ref: '#/components/schemas/Decimal' }]
|
||||
description: Доля, не проценты. 0.151 = 15,1 %. Округление до 6 знаков, half away from zero.
|
||||
Money:
|
||||
$ref: 'openapi.json#/components/schemas/MoneyDto'
|
||||
UnavailableReason:
|
||||
type: string
|
||||
enum:
|
||||
- HISTORY_START_UNKNOWN
|
||||
- OPERATIONS_HISTORY_INCOMPLETE
|
||||
- NON_RUB_ACTIVITY
|
||||
- UNVALUED_SECURITIES_TRANSFER
|
||||
- UNCONFIRMED_TRANSFER
|
||||
- ACCOUNT_VALUATION_DATES_DIFFER
|
||||
- PORTFOLIO_VALUATION_INCOMPLETE
|
||||
- NO_SOLUTION
|
||||
- AMBIGUOUS_RETURN
|
||||
- INDEX_HISTORY_UNAVAILABLE
|
||||
description: |
|
||||
Новые значения MAY добавляться; клиент MUST показывать неизвестное
|
||||
значение как общую недоступность, а не как ошибку.
|
||||
ReturnValue:
|
||||
type: object
|
||||
required: [value, basis, unavailableReason]
|
||||
properties:
|
||||
value:
|
||||
oneOf: [{ $ref: '#/components/schemas/Rate' }, { type: 'null' }]
|
||||
basis:
|
||||
type: string
|
||||
enum: [annual, period]
|
||||
description: annual при period.days >= 365, иначе period (доля за весь период)
|
||||
unavailableReason:
|
||||
oneOf: [{ $ref: '#/components/schemas/UnavailableReason' }, { type: 'null' }]
|
||||
IndexResult:
|
||||
type: object
|
||||
required: [code, value, return, asOf, unavailableReason]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [MCFTRR, IMOEX]
|
||||
value:
|
||||
oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
|
||||
description: Стоимость тех же потоков в индексе на конец периода (RUB)
|
||||
return: { $ref: '#/components/schemas/ReturnValue' }
|
||||
asOf:
|
||||
oneOf: [{ type: string, format: date }, { type: 'null' }]
|
||||
description: Дата последнего использованного закрытия индекса (Europe/Moscow)
|
||||
unavailableReason:
|
||||
oneOf: [{ $ref: '#/components/schemas/UnavailableReason' }, { type: 'null' }]
|
||||
Outperformance:
|
||||
type: object
|
||||
required: [percentagePoints, money]
|
||||
properties:
|
||||
percentagePoints:
|
||||
$ref: '#/components/schemas/Rate'
|
||||
description: Разница долей return пользователя и benchmark (того же basis)
|
||||
money: { $ref: '#/components/schemas/Money' }
|
||||
SeriesPoint:
|
||||
type: object
|
||||
required: [date, netInvested, benchmarkValue, referenceValue]
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
netInvested: { $ref: '#/components/schemas/Decimal' }
|
||||
benchmarkValue: { $ref: '#/components/schemas/Decimal' }
|
||||
referenceValue:
|
||||
oneOf: [{ $ref: '#/components/schemas/Decimal' }, { type: 'null' }]
|
||||
description: Значения RUB на торговый день MCFTRR; валюта точки всегда RUB
|
||||
ExcludedAccount:
|
||||
type: object
|
||||
required: [accountId, name, reason]
|
||||
properties:
|
||||
accountId: { type: string }
|
||||
name: { type: string }
|
||||
reason: { $ref: '#/components/schemas/UnavailableReason' }
|
||||
Performance:
|
||||
type: object
|
||||
required:
|
||||
- accountId
|
||||
- period
|
||||
- netInvested
|
||||
- currentValue
|
||||
- profit
|
||||
- return
|
||||
- benchmark
|
||||
- reference
|
||||
- outperformance
|
||||
- series
|
||||
- completeness
|
||||
- excludedAccounts
|
||||
- calculatedAt
|
||||
properties:
|
||||
accountId: { type: string, description: 'id счёта или all' }
|
||||
period:
|
||||
type: object
|
||||
required: [from, to, days]
|
||||
properties:
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date, description: 'дата последней полной оценки Portfolio' }
|
||||
days: { type: integer, minimum: 0 }
|
||||
netInvested: { $ref: '#/components/schemas/Money' }
|
||||
currentValue: { $ref: '#/components/schemas/Money' }
|
||||
profit: { $ref: '#/components/schemas/Money' }
|
||||
return: { $ref: '#/components/schemas/ReturnValue' }
|
||||
benchmark:
|
||||
allOf: [{ $ref: '#/components/schemas/IndexResult' }]
|
||||
description: code = MCFTRR
|
||||
reference:
|
||||
allOf: [{ $ref: '#/components/schemas/IndexResult' }]
|
||||
description: code = IMOEX; только справка
|
||||
outperformance:
|
||||
oneOf: [{ $ref: '#/components/schemas/Outperformance' }, { type: 'null' }]
|
||||
description: null, если недоступен return пользователя или benchmark
|
||||
series:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SeriesPoint' }
|
||||
description: Пусто, если benchmark недоступен
|
||||
completeness:
|
||||
type: string
|
||||
enum: [complete, incomplete]
|
||||
excludedAccounts:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ExcludedAccount' }
|
||||
description: Только для all; incomplete <=> непустой массив
|
||||
calculatedAt: { type: string, format: date-time }
|
||||
PerformanceResource:
|
||||
type: object
|
||||
required: [data, meta]
|
||||
properties:
|
||||
data:
|
||||
oneOf: [{ $ref: '#/components/schemas/Performance' }, { type: 'null' }]
|
||||
description: null, если результат недоступен целиком
|
||||
meta:
|
||||
type: object
|
||||
required: [dependencies, unavailable]
|
||||
properties:
|
||||
dependencies:
|
||||
type: array
|
||||
items: { $ref: 'openapi.json#/components/schemas/InvestmentDependencyDto' }
|
||||
unavailable:
|
||||
type: array
|
||||
description: Непусто <=> data = null; причины по каждому счёту
|
||||
items: { $ref: '#/components/schemas/ExcludedAccount' }
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
sequenceDiagram
|
||||
participant API as WealthLedger backend
|
||||
participant Idx as Saved index history
|
||||
participant MOEX as MOEX ISS
|
||||
API->>Idx: Последняя сохранённая дата, время последней попытки (по индексу)
|
||||
alt Покрыто до дня `to` или попытка была < 1 ч назад
|
||||
Idx-->>API: Сохранённые закрытия
|
||||
else Нужна догрузка (одна одновременная попытка на индекс)
|
||||
loop Страницы по 100 строк (start = 0, 100, …) до TOTAL
|
||||
API->>MOEX: GET /iss/history/engines/stock/markets/index/securities/{SECID}.json<br/>?from&till&start&iss.meta=off&history.columns=TRADEDATE,CLOSE
|
||||
alt 200 с валидными строками
|
||||
MOEX-->>API: TRADEDATE, CLOSE + history.cursor(INDEX,TOTAL,PAGESIZE)
|
||||
API->>Idx: Upsert (индекс, торговый день) → точное закрытие
|
||||
else Таймаут 10 с / не-2xx / невалидный ответ
|
||||
MOEX-->>API: сбой
|
||||
Note over API: Прервать догрузку; сохранённое не удалять;<br/>лог integration.moex.index_history.failed
|
||||
end
|
||||
end
|
||||
Idx-->>API: Сохранённые закрытия
|
||||
end
|
||||
alt Последнее закрытие ≥ to − 10 календарных дней
|
||||
Note over API: Покрытие достаточно; asOf = последняя дата закрытия
|
||||
else Иначе
|
||||
Note over API: INDEX_HISTORY_UNAVAILABLE для этого индекса
|
||||
end
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant UI as WealthLedger frontend
|
||||
participant API as WealthLedger backend
|
||||
participant Store as Saved investment data
|
||||
participant Idx as Saved index history
|
||||
participant MOEX as MOEX ISS
|
||||
User->>UI: Открывает вкладку «Результат» (accountId | all)
|
||||
UI->>API: GET /brokerage-accounts/{accountId|all}/performance
|
||||
API->>Store: Сессия, connection, доступ к счёту(ам)
|
||||
alt Нет сессии / нет подключения / счёт недоступен
|
||||
API-->>UI: Problem Details 401 / 503 TBANK_NOT_CONNECTED / 404
|
||||
UI-->>User: Существующие состояния ошибки счёта
|
||||
else Доступ подтверждён
|
||||
API->>Store: Граница истории, покрытие операций, последняя полная оценка Portfolio
|
||||
Note over API: По каждому счёту: причины недоступности<br/>(HISTORY_START_UNKNOWN, OPERATIONS_HISTORY_INCOMPLETE,<br/>NON_RUB_ACTIVITY, UNVALUED_SECURITIES_TRANSFER,<br/>PORTFOLIO_VALUATION_INCOMPLETE, UNCONFIRMED_TRANSFER)<br/>all: ACCOUNT_VALUATION_DATES_DIFFER<br/>XIRR: NO_SOLUTION / AMBIGUOUS_RETURN
|
||||
alt Не осталось счетов для расчёта
|
||||
API-->>UI: 200 {data: null, meta.unavailable: [...]}
|
||||
UI-->>User: «Доходность недоступна» + причина
|
||||
else Есть счета для расчёта
|
||||
API->>API: Внешние потоки по дате, чистые вложения, прибыль, XIRR
|
||||
API->>Idx: Закрытия MCFTRR, IMOEX за [from, to]
|
||||
opt История не покрывает период (см. index-refresh.mmd)
|
||||
Idx->>MOEX: Догрузка недостающих торговых дней
|
||||
end
|
||||
alt MCFTRR покрывает период
|
||||
API->>API: Теневой портфель, XIRR индекса, опережение, series
|
||||
else MCFTRR недоступен
|
||||
API->>API: benchmark/outperformance недоступны (INDEX_HISTORY_UNAVAILABLE), series = []
|
||||
end
|
||||
API-->>UI: 200 {data: Performance, meta.dependencies}
|
||||
UI-->>User: Показатели, график, «Как считали», предупреждение о неполноте
|
||||
end
|
||||
end
|
||||
alt Технический сбой backend
|
||||
API-->>UI: Problem Details 5xx
|
||||
UI-->>User: Ошибка только во вкладке «Результат», с повтором
|
||||
end
|
||||
Reference in New Issue
Block a user