17 KiB
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
-
Модуль
src/modules/investments/performance/устроен по образцуincome:- контроллер с маршрутом
all/performanceперед:accountId/performance; - application-сервис;
- чистый
domain/: потоки, XIRR, теневой портфель, причины.
Сервис зависит от
TbankAccessService,BrokerageOperationsService(чтение сохранённых операций и покрытия),PortfolioService.savedиMarketIndexHistoryService. Отклонено: расширятьincome— это другой предметный смысл, а там запрещён термин «доходность». - контроллер с маршрутом
-
Признак полной оценки выносится в чистую функцию
isCompleteValuation(snapshot)в portfolio domain и экспортируется черезportfolio.api.ts. Его используют иportfolio.repository.ts, и расчёт результата. Так у «полной оценки» одно правило. -
XIRR на
number, деньги — наDecimal. Агрегаты, потоки, units и стоимость теневого портфеля считаются точно. Ставка — итерационное приближение, точная десятичная степень для неё бессмысленна. Поэтому решатель работает на float64 и выдаёт результат до 6 знаков (точности хватает с запасом).- При обычном наборе потоков с одной сменой знака по датам Descartes'
rule доказывает единственность корня: уточнить его бисекцией в
[−0.9999, 100]до |NPV| ≤ 0,01 ₽. Нет корня →NO_SOLUTION. При нескольких сменах знака нет доказательства единственности — пока консервативно вернутьAMBIGUOUS_RETURN, даже если фактически корень единственный. Полное выделение всех корней добавлять только при подтверждённой потребности. - Отклонено: Ньютон от одного старта и проверка только знаков на концах: они не выявляют несколько решений. Новая зависимость не нужна.
- При обычном наборе потоков с одной сменой знака по датам Descartes'
rule доказывает единственность корня: уточнить его бисекцией в
-
Переводы в
all. Деньги и бумаги сопоставляются в domain только для выявления кандидатов (сумма/время либо инструмент/количество). Кандидат без независимого признака связи не исключается из потоков: затронутые счета исключаются из результатаallс причинойUNCONFIRMED_TRANSFER. Существующий T-Bank contract не даёт такого признака, поэтому автоматически подтверждённых пар сейчас нет. -
История индексов:
MarketIndexHistoryServiceвsrc/modules/investments/market-index/. ВMoexClientдобавляется методindexHistory(secid, from, till)с пагинацией поhistory.cursor. Если страница не проходит валидацию, вся загрузка считается сбоем, а частичные строки не сохраняются.- Single-flight:
Map<secid, Promise>с удалением вfinally, по образцу существующегоpendingmap. - Время последней попытки хранится в БД, а не в памяти: правило «не чаще раза в час» переживает рестарт.
- Single-flight:
-
Расчёт без кэша. Каждый GET пересчитывает результат из сохранённых данных, это O(операций + дней). Кэш результата не нужен, пока ответ укладывается в 1 с (см. NFR).
-
Маппинг типов операций — единая таблица в domain (enum T-Bank → роль
deposit | withdrawal | securitiesTransfer | internal) рядом с существующимbrokerage-operation.mapper.ts. Существующие категории журнала не меняются.
frontend
-
Entity
src/entities/performance/: хукusePerformance(accountId)наapiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/performance")сmeta.accessGroup: "brokerage"иstaleTime: 30_000. Дляallпередаётся литерал пути, как в income. -
Вкладка
performance(«Результат») — последняя вAccountPageLoaded.view.tsx. Панель — самостоятельный containerPerformanceTabвsrc/pages/account/ui/AccountPage/components/Performance/. Хук вызывается внутри панели, поэтому запрос уходит только при открытой вкладке (keepMounted={false}). Ошибка вкладки не задевает остальные вкладки. -
VM (
Performance.vm.ts) переводит ответ в состояние вкладки:loading,error;unavailable: причины изmeta.unavailable;ready: KPI, график, предупреждения.
Вне VM доли не превращаются в проценты. Подпись берётся из
return.basisиperiod.days. Тон:> 0— positive,< 0— negative,0— neutral. НеизвестныйUnavailableReasonпоказывается общей формулировкой. -
Форматирование. Добавить в
shared/lib/display.ts:formatSignedPercent(rate): доля → «+15,1 %», 1 знак;formatPercentagePoints(rate): «+4,3 п.п.».
Используется минус «−» (U+2212), как в прототипе. Суммы со знаком выводятся через
formatMoney. -
График —
@mantine/chartsLineChart:- серии:
netInvested(curveType="stepAfter"),benchmarkValue,referenceValue(strokeDasharray); - текущая стоимость —
ReferenceDotиз recharts в датуperiod.to; - пользовательский tooltip — по образцу
PortfolioStructure.
Текстовая альтернатива:
aria-labelс итогами плюс скрытая визуально таблица итоговых значений. Полной таблицы точек нет: она вне scope, как и таблица потоков. Еслиseriesпуст, графика нет, вместо него текст о недоступности индекса. - серии:
-
Строки интерфейса — в
ru.ts, namespaceportfolio.performance.*, включая тексты причин и блок «Как считали». -
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). Расчёт пишет debugperformance.calculated(accountsIncluded, accountsExcluded, durationMs) без сумм. - Конкурентность. Single-flight догрузки на каждый индекс. Upsert по
(secid, tradeDate)идемпотентен. - Ошибки. Сбой MOEX ловится в
MarketIndexHistoryServiceи превращается в результат «недостаточно покрытия». Наружу как 503 он не выходит.
Data / Storage Changes
Аддитивная миграция prisma/migrations/<ts>_add_market_index_history:
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или Pythonscipy.optimize, и записываются как константы с допуском 1e-6; - примеры из спецификации: 10 % на 730 дней, 110 000 ₽ по индексу;
basis = period,NO_SOLUTION,AMBIGUOUS_RETURN(несколько корней), поток в выходной;- кандидаты на переводы без подтверждённой связи, порядок причин;
- XIRR на эталонных наборах: значения считаются заранее независимо, в
LibreOffice
- тест клиента 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-карточек (
SimpleGridcols 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 может измениться. Мера: строгий разбор, сбой вместо частичных данных, сохранённая история остаётся.