20 KiB
System Analysis
Issue: WealthLedger/main#54
Requirement Coverage
| Нормативная область | Поток и контракт |
|---|---|
investment-performance: результат счёта и all, доступ |
performance-read.mmd, contract Performance, PerformanceResource |
investment-performance: внешние потоки, переводы в 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, Индексы 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: чтение результата со всеми ветками доступа, недоступности и сбоя индекса.
- 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 | Новые маршруты, аддитивно. 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 |
Внешнее пополнение: `+ |
OUTPUT, OUTPUT_ACQUIRING, OUTPUT_SWIFT, OUT_MULTI |
Внешний вывод: `− |
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 ≤ fetchedAtPortfolio этого счёта. Иначе пополнение после оценки исказит результат. - Дата потока — календарный день
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 отложено до появления
подтверждённой связи; не вычислять процент на предположении.
Причины недоступности
Причины проверяются по каждому счёту в таком порядке; используется первая сработавшая:
HISTORY_START_UNKNOWN:historyBoundary = unknown.OPERATIONS_HISTORY_INCOMPLETE: покрытие неcomplete.PORTFOLIO_VALUATION_INCOMPLETE: нет Portfolio или у ненулевой позиции нет вычислимой стоимости (то же правило, что уportfolio-value-history).UNVALUED_SECURITIES_TRANSFER.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.
- Нет корня или корень не найден с точностью |NPV| ≤ 0,01 ₽ →
-
Теневой портфель. Для каждого потока берётся закрытие индекса на дату потока или на последнюю торговую дату до неё:
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 и прототипе текст обновлён: вывод бумаг без оценки так же искажает расчёт.