Files
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

262 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** может измениться. Мера: строгий разбор, сбой вместо
частичных данных, сохранённая история остаётся.