262 lines
17 KiB
Markdown
262 lines
17 KiB
Markdown
# 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** может измениться. Мера: строгий разбор, сбой вместо
|
||
частичных данных, сохранённая история остаётся.
|