Files
specs/openspec/changes/2026-11-07-investment-performance-vs-index/design.md
T
codex-bot 892800301b
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) (#85)
2026-11-08 16:30:32 +03:00

17 KiB
Raw Blame History

Design

Issue: WealthLedger/main#54

Context and Constraints

  • Backend: NestJS, Prisma + SQLite. Деньги — точная BigInt-арифметика (src/common/decimal.ts: add, subtract, multiply, divide, compare). Возведения в дробную степень нет.
  • Операции T-Bank уже сохраняются синхронизацией. Покрытие и граница истории вычисляются в operations/domain/operations-coverage.ts. Доступ и режим all проверяет TbankAccessService.local. Последний Portfolio отдаёт PortfolioService.saved (portfolio.api.ts).
  • Признак полной оценки Portfolio сейчас записан прямо в portfolio.repository.ts:141-146 и нигде больше не используется.
  • MOEX-клиент (integrations/moex/moex.client.ts) использует fetch с таймаутом 10 с и умеет читать ISS-блоки и cursor-пагинацию. Он отдаёт только данные облигаций.
  • Frontend: вкладки счёта — Mantine Tabs с keepMounted={false}, состояние во VM. Данные берутся из entity-хуков на openapi-react-query. Графики — @mantine/charts (AreaChart в InstrumentChart). Компоненты разбиты на container / vm / view. Строки интерфейса лежат в ru.ts.

Technical Goals / Non-Goals

Goals: реализовать утверждённый контракт и спецификации. Расчёт выполняется на backend и сверяется с независимым эталоном XIRR. Вкладка «Результат» соответствует варианту A прототипа.

Non-Goals:

  • общий финансовый движок «на будущее» (TWR, периоды);
  • фоновая синхронизация индексов;
  • изменения синхронизации T-Bank;
  • новая зависимость ради XIRR.

Implementation Impact Map

Area Статус Outcome
main N/A Нет изменений compose, env или релиза. MOEX ISS уже используется без конфигурации
backend affected Модуль performance, хранилище и клиент истории индексов, миграция, маршруты, обновлённый openapi.json
frontend affected Обновлённый OpenAPI snapshot, entity performance, вкладка «Результат»
qa N/A Отдельного QA-репозитория нет

Decisions

backend

  1. Модуль src/modules/investments/performance/ устроен по образцу income:

    • контроллер с маршрутом all/performance перед :accountId/performance;
    • application-сервис;
    • чистый domain/: потоки, XIRR, теневой портфель, причины.

    Сервис зависит от TbankAccessService, BrokerageOperationsService (чтение сохранённых операций и покрытия), PortfolioService.saved и MarketIndexHistoryService. Отклонено: расширять income — это другой предметный смысл, а там запрещён термин «доходность».

  2. Признак полной оценки выносится в чистую функцию isCompleteValuation(snapshot) в portfolio domain и экспортируется через portfolio.api.ts. Его используют и portfolio.repository.ts, и расчёт результата. Так у «полной оценки» одно правило.

  3. XIRR на number, деньги — на Decimal. Агрегаты, потоки, units и стоимость теневого портфеля считаются точно. Ставка — итерационное приближение, точная десятичная степень для неё бессмысленна. Поэтому решатель работает на float64 и выдаёт результат до 6 знаков (точности хватает с запасом).

    • При обычном наборе потоков с одной сменой знака по датам Descartes' rule доказывает единственность корня: уточнить его бисекцией в [−0.9999, 100] до |NPV| ≤ 0,01 ₽. Нет корня → NO_SOLUTION. При нескольких сменах знака нет доказательства единственности — пока консервативно вернуть AMBIGUOUS_RETURN, даже если фактически корень единственный. Полное выделение всех корней добавлять только при подтверждённой потребности.
    • Отклонено: Ньютон от одного старта и проверка только знаков на концах: они не выявляют несколько решений. Новая зависимость не нужна.
  4. Переводы в all. Деньги и бумаги сопоставляются в domain только для выявления кандидатов (сумма/время либо инструмент/количество). Кандидат без независимого признака связи не исключается из потоков: затронутые счета исключаются из результата all с причиной UNCONFIRMED_TRANSFER. Существующий T-Bank contract не даёт такого признака, поэтому автоматически подтверждённых пар сейчас нет.

  5. История индексов: MarketIndexHistoryService в src/modules/investments/market-index/. В MoexClient добавляется метод indexHistory(secid, from, till) с пагинацией по history.cursor. Если страница не проходит валидацию, вся загрузка считается сбоем, а частичные строки не сохраняются.

    • Single-flight: Map<secid, Promise> с удалением в finally, по образцу существующего pending map.
    • Время последней попытки хранится в БД, а не в памяти: правило «не чаще раза в час» переживает рестарт.
  6. Расчёт без кэша. Каждый GET пересчитывает результат из сохранённых данных, это O(операций + дней). Кэш результата не нужен, пока ответ укладывается в 1 с (см. NFR).

  7. Маппинг типов операций — единая таблица в domain (enum T-Bank → роль deposit | withdrawal | securitiesTransfer | internal) рядом с существующим brokerage-operation.mapper.ts. Существующие категории журнала не меняются.

frontend

  1. Entity src/entities/performance/: хук usePerformance(accountId) на apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/performance") с meta.accessGroup: "brokerage" и staleTime: 30_000. Для all передаётся литерал пути, как в income.

  2. Вкладка performance («Результат») — последняя в AccountPageLoaded.view.tsx. Панель — самостоятельный container PerformanceTab в src/pages/account/ui/AccountPage/components/Performance/. Хук вызывается внутри панели, поэтому запрос уходит только при открытой вкладке (keepMounted={false}). Ошибка вкладки не задевает остальные вкладки.

  3. VM (Performance.vm.ts) переводит ответ в состояние вкладки:

    • loading, error;
    • unavailable: причины из meta.unavailable;
    • ready: KPI, график, предупреждения.

    Вне VM доли не превращаются в проценты. Подпись берётся из return.basis и period.days. Тон: > 0 — positive, < 0 — negative, 0 — neutral. Неизвестный UnavailableReason показывается общей формулировкой.

  4. Форматирование. Добавить в shared/lib/display.ts:

    • formatSignedPercent(rate): доля → «+15,1 %», 1 знак;
    • formatPercentagePoints(rate): «+4,3 п.п.».

    Используется минус «−» (U+2212), как в прототипе. Суммы со знаком выводятся через formatMoney.

  5. График — @mantine/charts LineChart:

    • серии: netInvested (curveType="stepAfter"), benchmarkValue, referenceValue (strokeDasharray);
    • текущая стоимость — ReferenceDot из recharts в дату period.to;
    • пользовательский tooltip — по образцу PortfolioStructure.

    Текстовая альтернатива: aria-label с итогами плюс скрытая визуально таблица итоговых значений. Полной таблицы точек нет: она вне scope, как и таблица потоков. Если series пуст, графика нет, вместо него текст о недоступности индекса.

  6. Строки интерфейса — в ru.ts, namespace portfolio.performance.*, включая тексты причин и блок «Как считали».

  7. Hint «?» — Mantine Tooltip с events={{ hover: true, focus: true, touch: true }} на ActionIcon с aria-label.

Cross-cutting Implementation

  • Безопасность. До чтения данных — TbankAccessService.local. Счета без доступа не попадают ни в расчёт, ни в meta.unavailable.
  • Логирование. integration.moex.index_history.succeeded/failed (secid, from, till, pages, durationMs). Расчёт пишет debug performance.calculated (accountsIncluded, accountsExcluded, durationMs) без сумм.
  • Конкурентность. Single-flight догрузки на каждый индекс. Upsert по (secid, tradeDate) идемпотентен.
  • Ошибки. Сбой MOEX ловится в MarketIndexHistoryService и превращается в результат «недостаточно покрытия». Наружу как 503 он не выходит.

Data / Storage Changes

Аддитивная миграция prisma/migrations/<ts>_add_market_index_history:

model MarketIndexDailyClose {
  secid     String   // MCFTRR | IMOEX
  tradeDate String   // YYYY-MM-DD, Europe/Moscow
  close     String   // точная десятичная строка
  createdAt DateTime @default(now())
  @@id([secid, tradeDate])
}

model MarketIndexSyncState {
  secid         String    @id
  lastAttemptAt DateTime?
  lastSuccessAt DateTime?
}
  • Данные публичные, к пользователю не привязаны. Удаление подключения их не трогает.
  • Backfill не нужен. Откат — drop двух таблиц; пользовательские данные не затрагиваются.
  • Миграция проверяется на одноразовой копии БД по .ai/conventions/data.md.

Contract Implementation

  • DTO повторяют system-analysis/contracts/investment-performance.openapi.yaml; UnavailableReason — экспортируемый enum.
  • После npm run openapi:generate полученный openapi.json сверяется с целевым контрактом по ключевым свойствам: маршруты, required-поля, nullable, enum, форматы Rate/Decimal. Расхождение возвращает change в System Analysis.
  • npm run openapi:check входит в gates.
  • Frontend копирует новый openapi.json в src/shared/api/spec/ и перегенерирует типы (openapi:generate). openapi:check входит в его verify.

Developer Verification Strategy

backend:

  • unit-тесты domain:
    • XIRR на эталонных наборах: значения считаются заранее независимо, в LibreOffice XIRR или Python scipy.optimize, и записываются как константы с допуском 1e-6;
    • примеры из спецификации: 10 % на 730 дней, 110 000 ₽ по индексу;
    • basis = period, NO_SOLUTION, AMBIGUOUS_RETURN (несколько корней), поток в выходной;
    • кандидаты на переводы без подтверждённой связи, порядок причин;
  • тест клиента MOEX: пагинация, невалидная страница, таймаут — на подменённом fetch, как в moex.client.spec.ts;
  • e2e на SQLite: маршрут, доступ, all, сбой MOEX с сохранённой и без сохранённой истории;
  • npm run verify, npm run openapi:check.

frontend:

  • тесты VM: состояния, подписи basis, тоны, неизвестная причина;
  • тесты view через RTL + MSW: обгоняю, отстаю, меньше года, неполный, недоступно, загрузка, ошибка;
  • browser-тест вёрстки 390px;
  • npm run verify.

UI/UX Package

new. Источник — prototype/index.html (sha256 b7d69ecb4e905350f30d41f89c9f7f5431101edba59478159cd88b761517f0b0), вариант A. Решения Change Owner от 2026-11-07: таблицы потоков нет, коды индексов только в «Как считали».

  • Экран: вкладка «Результат» на /accounts/:accountId (и all).
  • Состояния: загрузка (skeleton из 4 карточек и графика), обгоняю, отстаю, меньше года (info-alert и «за N дн.»), неполный (warning-alert со списком исключённых счетов), недоступно (центрированный блок с причиной), ошибка (существующий паттерн ошибки секции с повтором), индекс недоступен (KPI без сравнения, текст вместо графика).
  • Компоненты: сетка из 4 KPI-карточек (SimpleGrid cols 1/2/4, как CurrencyCards), Paper с графиком, Accordion «Как считали». Цвета — токены --wl-color-positive/negative, серии — blue.6 (индекс), dark.4 (вложено), gray.5 пунктир (без дивидендов).
  • Responsive. На 390px одна колонка карточек, нет горизонтальной прокрутки, у графика h={220}.
  • A11y:
    • смысл передаётся знаком и текстом, а не только цветом;
    • hint доступен с клавиатуры;
    • у графика есть текстовая альтернатива;
    • alert'ы имеют role="status";
    • анимация skeleton уважает prefers-reduced-motion (Mantine по умолчанию).

Rollout / Rollback

Один релиз backend и frontend; backend выкатывается первым (новые маршруты аддитивны). Feature flag не нужен. Откат: frontend без вкладки, затем backend — маршруты и drop таблиц индексов.

Technical Risks

  • Неподтверждённые переводы делают результат all неполным. Меры: показать исключённые счета и причину; проверить на реальных данных, появится ли надёжный признак связи.
  • Неоднозначность XIRR при крупных выводах (несколько корней). Мера: поиск возможных корней и AMBIGUOUS_RETURN, а не произвольный корень.
  • Формат ISS может измениться. Мера: строгий разбор, сбой вместо частичных данных, сохранённая история остаётся.