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

20 KiB
Raw Permalink Blame History

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