spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) (#85)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s

This commit was merged in pull request #85.
This commit is contained in:
2026-11-08 16:30:32 +03:00
parent 1b20067ca7
commit 892800301b
13 changed files with 1641 additions and 0 deletions
@@ -0,0 +1,261 @@
# 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`:
```prisma
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** может измениться. Мера: строгий разбор, сбой вместо
частичных данных, сохранённая история остаётся.