# 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` с удалением в `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/_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** может измениться. Мера: строгий разбор, сбой вместо частичных данных, сохранённая история остаётся.