spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) (#85)
This commit was merged in pull request #85.
This commit is contained in:
@@ -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** может измениться. Мера: строгий разбор, сбой вместо
|
||||
частичных данных, сохранённая история остаётся.
|
||||
Reference in New Issue
Block a user