spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) #85
@@ -0,0 +1,2 @@
|
||||
schema: wealth-ledger-sdd-v2
|
||||
created: 2026-11-07
|
||||
@@ -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** может измениться. Мера: строгий разбор, сбой вместо
|
||||
частичных данных, сохранённая история остаётся.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Docs Impact
|
||||
|
||||
Issue: WealthLedger/main#54
|
||||
|
||||
## Summary
|
||||
|
||||
Обновление долговременной документации нужно. Появляется новое понятие
|
||||
«Результат инвестирования» с методикой. Из-за него снимается общий запрет
|
||||
на термин «доходность», и это архитектурно значимое решение: оно требует
|
||||
ADR. Ещё добавляются новый источник данных (индексы MOEX), сохраняемая
|
||||
публичная история и новые маршруты HTTP API. Существующие формулировки
|
||||
«не доходность» для других понятий сохраняются, но получают ссылку на новое
|
||||
понятие.
|
||||
|
||||
## Documentation Changes
|
||||
|
||||
| Action | Path | Content | Source | Reason |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| create | `docs/adr/023-investment-performance-vs-index.md` | Решение: XIRR как «доходность»; сравнение с теми же потоками в MCFTRR (PME по Long–Nickels), IMOEX только для справки; только RUB; «за N дн.» при истории меньше года; отклонены простой ROI, TWR без истории стоимости, ценовой IMOEX как ориентир и конвертация валют; сохранение публичных закрытий индексов как исключение из ADR 019; оговорка о налоговых ставках MCFTRR и условиях ISS | proposal, system-analysis «Расчёт», «Индексы MOEX», design Decisions backend 3, 5 | Архитектурно значимое решение о методике и хранимых данных |
|
||||
| create | `docs/domains/portfolio/investment-performance.md` | Что показывает вкладка «Результат», внешние и внутренние потоки, смысл прибыли, доходности и «тех же денег в индексе», опережение и отставание, правило меньше года, «Все счета» и переводы между своими счетами, причины недоступности и неполноты, отсутствие линии прошлой стоимости, ссылки на спецификацию и HTTP API | specs `investment-performance`, system-analysis «Маппинг операций», «Переводы», «Причины недоступности» | Новая долговременная предметная область |
|
||||
| update | `docs/glossary.md` | Новые термины: «Результат инвестирования», «Доходность (XIRR)», «Те же деньги в индексе Мосбиржи», «Опережение рынка», «Внешний денежный поток», каждый с _Не использовать_. У «Точки истории стоимости портфеля» и «Realized Income Analytics» вместо запрета «доходность» — уточнение «не является результатом инвестирования» со ссылкой | specs, ADR 023 | Терминология и снятие конфликтующего запрета |
|
||||
| update | `docs/overview.md` | Вкладка «Результат» в списке возможностей; абзац «что есть сейчас / что исполнено…» дополнить вопросом «как сработали вложенные деньги». Оговорка, что выплаты сами по себе не равны доходности, сохраняется | proposal | Обзор продукта |
|
||||
| update | `docs/domains/portfolio/overview.md` | Строки 3–6 и 31: снимок по-прежнему не является расчётом полной доходности; полная доходность считается в «Результате инвестирования» (ссылка) | specs | Устранить противоречие |
|
||||
| update | `docs/domains/portfolio/all-accounts.md` | «Результат» в режиме «Все счета»: исключение счетов с пометкой о неполноте, переводы между включёнными счетами | specs | Поведение режима `all` |
|
||||
| update | `docs/domains/portfolio/data-reference.md` | Поля результата: доли (`Rate`), `basis`, `UnavailableReason` и их отображение; деньги в RUB | system-analysis «Contracts» | Справочник смысла данных |
|
||||
| update | `docs/domains/income/overview.md` | Строка 97: вместо «не рассчитывает доходность» — «не рассчитывает доходность; её показывает Результат инвестирования» (ссылка) | specs | Навигация и отсутствие противоречия |
|
||||
| update | `docs/integrations/moex.md` | Раздел «Индексы»: MCFTRR и IMOEX, маршрут ISS history, пагинация, догрузка при чтении не чаще раза в час, локальное хранение закрытий, поведение при недоступности, события лога, условия использования данных ISS | system-analysis «Индексы MOEX», design 5 | Новый поток интеграции |
|
||||
| update | `docs/architecture/context.md` | Строка 34: MOEX поставляет также дневные закрытия индексов Мосбиржи | system-analysis | Контекст системы |
|
||||
| update | `docs/architecture/http-api.md` | Маршруты `GET /brokerage-accounts/{accountId\|all}/performance`: смысл `data: null`, `meta.unavailable`, `completeness`; ссылка на backend-generated OpenAPI, без копии схемы | contract | Описание HTTP API |
|
||||
| update | `docs/adr/README.md` | Строка индекса ADR 023 | ADR 023 | Навигация |
|
||||
|
||||
## Diagrams
|
||||
|
||||
Отдельная диаграмма не переносится. Последовательности чтения и догрузки
|
||||
остаются в архивированном change. В `docs/integrations/moex.md` достаточно
|
||||
текстового описания догрузки.
|
||||
|
||||
## ADR
|
||||
|
||||
- **Создать** ADR 023 (см. таблицу), статус Accepted. Ссылки: Issue
|
||||
WealthLedger/main#54 и этот change.
|
||||
- ADR 019 не переписывается: ADR 023 явно дополняет его исключением для
|
||||
публичной истории индексов, по образцу ADR 022.
|
||||
|
||||
## Non-Changes
|
||||
|
||||
- `docs/domains/instruments/bonds.md`, `stocks.md`: «открытый результат не
|
||||
является доходностью к погашению» — другое понятие, текст не меняется.
|
||||
- `docs/architecture/containers.md`: состав контейнеров не меняется (MOEX
|
||||
уже есть).
|
||||
- `docs/architecture/deployment.md`: конфигурация и сеть не меняются.
|
||||
- `docs/design/`: новых долговременных визуальных принципов нет; вкладка
|
||||
использует существующие паттерны.
|
||||
- `docs/domains/operations/`: журнал операций не меняется; маппинг типов на
|
||||
потоки описывается в новой странице результата.
|
||||
|
||||
## Verification
|
||||
|
||||
- После `[docs]` tasks выполнить `npm run verify` в `docs/` (markdownlint
|
||||
с проверкой относительных ссылок и `git diff --check`).
|
||||
- Термин «доходность» в `docs/` встречается только в значении «Результат
|
||||
инвестирования» или в явных оговорках «не является»:
|
||||
`grep -rn "доходност" docs/`.
|
||||
- Ссылки на specs и OpenAPI ведут на commit после merge реализации.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Proposal
|
||||
|
||||
Issue: WealthLedger/main#54
|
||||
|
||||
## Why
|
||||
|
||||
Пользователь хочет знать, насколько хорошо у него получается инвестировать.
|
||||
Конкретно: какой процент приносят вложенные деньги и обгоняет ли он индекс
|
||||
Московской биржи. Сейчас приложение показывает только нереализованный результат
|
||||
открытых позиций относительно средней цены покупки. Этот показатель не учитывает:
|
||||
|
||||
- когда и сколько денег вносилось и выводилось;
|
||||
- результат закрытых позиций;
|
||||
- купоны, дивиденды, налоги и комиссии.
|
||||
|
||||
Поэтому из него нельзя получить ни честный процент годовых, ни сравнение с
|
||||
рынком. Текущие принципы продукта прямо запрещают называть показатели
|
||||
«доходностью», потому что честного расчёта не было. Это изменение вводит
|
||||
такой расчёт с явной методикой.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Для брокерского счёта и режима «Все счета» появляется результат
|
||||
инвестирования за всё время. В него входят:
|
||||
- **прибыль** в рублях: текущая стоимость минус чистые внешние пополнения;
|
||||
- **доходность** с учётом сумм и дат пополнений и выводов (money-weighted, XIRR).
|
||||
- Появляется сравнение с альтернативой «те же деньги в индексе Мосбиржи». Те же
|
||||
пополнения и выводы в те же даты применяются к индексу Мосбиржи с учётом
|
||||
дивидендов после налогов. Показываются его стоимость, его доходность и
|
||||
**опережение или отставание** пользователя в процентных пунктах и рублях.
|
||||
- Привычный ценовой индекс Мосбиржи (без дивидендов) показывается только для
|
||||
справки и в сравнении не участвует.
|
||||
- Пока история короче года, проценты показываются за фактический период, без
|
||||
пересчёта в годовые.
|
||||
- Когда честный расчёт невозможен, результат помечается как «недоступно» или
|
||||
«неполный» с конкретной причиной, а не показывается нулём или
|
||||
приблизительным числом.
|
||||
- На странице счёта появляется вкладка «Результат»: ключевые показатели,
|
||||
график «вложено / те же деньги в индексе / справочный индекс / текущая
|
||||
стоимость» и пояснение «Как считали». Утверждённый вид — вариант A прототипа
|
||||
`prototype/index.html`.
|
||||
- Термин «доходность» разрешается для этой метрики с точным определением.
|
||||
Остальные запреты на этот термин сохраняются.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Результат за всё время для одного брокерского счёта и для «Все счета».
|
||||
- Только рублёвые счета и потоки. В «Все счета» счёт, который нельзя посчитать,
|
||||
исключается с явной пометкой о неполноте результата. Если не осталось ни
|
||||
одного счёта, который можно посчитать, результат «недоступен».
|
||||
- Переводы денег и бумаг между счетами, включёнными в «Все счета», не считаются
|
||||
внешними пополнениями и выводами.
|
||||
- Получение и локальное хранение дневной истории двух индексов Мосбиржи: с
|
||||
дивидендами после налогов и ценового. Недоступность источника не мешает
|
||||
остальным разделам счёта.
|
||||
- Нормативное поведение, публичный контракт для интерфейса, вкладка
|
||||
«Результат», методика и глоссарий в документации.
|
||||
|
||||
### Out of Scope / Non-Goals
|
||||
|
||||
- Доходность, взвешенная по времени (TWR), периоды «с начала года», «за год» и
|
||||
произвольные, а также восстановление прошлой стоимости портфеля. Линия
|
||||
стоимости портфеля в прошлом на графике не появляется.
|
||||
- Валютная конвертация и счета с потоками или позициями не в рублях.
|
||||
- Выбор другого ориентира (облигации, инфляция, депозит, составной индекс).
|
||||
- Доходность отдельных позиций, налоговый расчёт, прогнозы, риск-метрики,
|
||||
доказательство «альфы».
|
||||
- Таблица пополнений и выводов с расчётом паёв индекса.
|
||||
- Изменение существующих показателей «нереализованный результат» и «Доход».
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `specs/portfolio/investment-performance/spec.md`: результат инвестирования
|
||||
счёта и «Все счета», сравнение с индексом Мосбиржи, состояния неполноты и
|
||||
недоступности, вкладка «Результат».
|
||||
- `specs/instruments/market-index-history/spec.md`: получение и хранение
|
||||
дневной истории индексов Мосбиржи и поведение при недоступности источника.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Существующие требования к портфелю, доходу и истории оценок не меняются.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Пользователь:** получает ответ на вопросы «сколько % годовых» и «обгоняю ли
|
||||
я рынок» на странице счёта.
|
||||
- **Домены:** портфель (новая метрика), операции брокерского счёта (источник
|
||||
внешних денежных потоков), инструменты и рыночные данные (история индексов).
|
||||
- **Внешние системы:** новая зависимость от публичных исторических данных
|
||||
Московской биржи (индексы) и от полноты истории операций T-Bank.
|
||||
- **Затрагиваемые области:** behavioral specs, backend (расчёт, данные индексов,
|
||||
публичный контракт), frontend (вкладка), docs (ADR о методике и термине,
|
||||
глоссарий, домены, интеграция с биржей).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Для рублёвого счёта с известным началом истории пользователь видит прибыль,
|
||||
доходность, результат «тех же денег в индексе» и опережение. Значения
|
||||
совпадают с независимым расчётом XIRR на тех же потоках с точностью до 0,1 п.п.
|
||||
- При истории короче года ни один процент не подписан как «годовых».
|
||||
- Ни в одном из случаев «начало истории неизвестно», «операции не в рублях»,
|
||||
«ввод бумаг без денежной оценки», «расчёт не сходится», «история индекса
|
||||
недоступна» не показывается число, выдаваемое за результат. Причина видна
|
||||
пользователю.
|
||||
- Отказ источника индексов не ломает другие вкладки счёта.
|
||||
|
||||
## Assumptions / Dependencies
|
||||
|
||||
- История операций T-Bank с даты открытия счёта содержит все пополнения и
|
||||
выводы рублей. Это проверяется на реальных данных первым запуском.
|
||||
- Индекс с дивидендами после налогов рассчитан по налоговым ставкам для
|
||||
организаций, а не по НДФЛ физлица. Это ориентир альтернативы, а не точная
|
||||
модель налогов пользователя. Это фиксируется в методике.
|
||||
- Публичные данные Мосбиржи используются в личном локальном приложении
|
||||
с указанием источника. Публичное распространение не планируется.
|
||||
- Текущая стоимость портфеля берётся из последней полной оценки. Если
|
||||
оценка неполная, результат недоступен.
|
||||
@@ -0,0 +1,265 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Прототип: вкладка «Результат» (WealthLedger/main#54)</title>
|
||||
<style>
|
||||
/* Значения взяты из frontend/packages/tokens/tokens.json */
|
||||
:root {
|
||||
--canvas: #e9ecef; --surface: #fff; --subtle: #f8f9fa; --text: #242424; --muted: #495057;
|
||||
--border: #dee2e6; --primary: #2563eb; --primary-0: #eff6ff; --positive: #237032; --positive-0: #ebfbee;
|
||||
--negative: #c92a2a; --negative-0: #fff5f5; --warning: #fab005; --warning-0: #fff9db;
|
||||
--c-portfolio: #2563eb; --c-index: #f76707; --c-imoex: #adb5bd; --c-invested: #495057;
|
||||
--r: 8px; --sp-xs: 10px; --sp-sm: 12px; --sp-md: 16px; --sp-lg: 20px; --sp-xl: 32px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--canvas); color: var(--text);
|
||||
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.toolbar { position: sticky; top: 0; z-index: 5; display: flex; flex-wrap: wrap; gap: var(--sp-md);
|
||||
align-items: center; padding: var(--sp-sm) var(--sp-lg); background: #212529; color: #fff; font-size: 14px; }
|
||||
.toolbar fieldset { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-width: 0; }
|
||||
.toolbar legend { float: left; margin-right: 8px; color: #adb5bd; }
|
||||
.toolbar label { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: #343a40; }
|
||||
.toolbar input { position: absolute; opacity: 0; }
|
||||
.toolbar input:checked + span { color: #fff; }
|
||||
.toolbar label:has(input:checked) { background: var(--primary); }
|
||||
.toolbar label:has(input:focus-visible) { outline: 2px solid #93c5fd; }
|
||||
main { max-width: 1040px; margin: 0 auto; padding: var(--sp-lg); }
|
||||
h1 { font-size: 26px; line-height: 1.35; margin: 0 0 4px; }
|
||||
.sub { color: var(--muted); font-size: 14px; margin: 0 0 var(--sp-md); }
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 2px solid var(--border); margin-bottom: var(--sp-lg); overflow-x: auto; }
|
||||
.tabs span { padding: 8px 14px; font-size: 14px; color: var(--muted); white-space: nowrap; }
|
||||
.tabs .active { color: var(--primary); font-weight: 600; border-bottom: 2px solid var(--primary); margin-bottom: -2px; }
|
||||
.paper { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); padding: var(--sp-md); }
|
||||
.stack { display: grid; gap: var(--sp-md); grid-template-columns: minmax(0, 1fr); }
|
||||
.grid { display: grid; gap: var(--sp-sm); grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
|
||||
.kpi { margin: 0; }
|
||||
.kpi dt { font-size: 14px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
|
||||
.kpi dd { margin: 2px 0 0; font-size: 26px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
||||
.kpi .note { font-size: 13px; color: var(--muted); font-weight: 400; }
|
||||
.pos { color: var(--positive); } .neg { color: var(--negative); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 13px; font-weight: 600; }
|
||||
.badge.pos { background: var(--positive-0); } .badge.neg { background: var(--negative-0); }
|
||||
.badge.muted { background: var(--subtle); color: var(--muted); border: 1px solid var(--border); }
|
||||
.hint { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
|
||||
border: 1px solid var(--muted); font-size: 11px; cursor: help; color: var(--muted); }
|
||||
.section-title { font-size: 18px; font-weight: 600; margin: 0 0 var(--sp-xs); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: var(--sp-md); font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||
.legend i { display: inline-block; width: 14px; height: 3px; vertical-align: middle; margin-right: 6px; border-radius: 2px; }
|
||||
.legend i.dash { background: repeating-linear-gradient(90deg, var(--c-imoex) 0 4px, transparent 4px 7px); }
|
||||
svg { width: 100%; height: auto; display: block; }
|
||||
.chart-wrap { position: relative; }
|
||||
.tooltip { position: absolute; pointer-events: none; background: #212529; color: #fff; font-size: 12px; padding: 6px 8px;
|
||||
border-radius: 4px; white-space: nowrap; transform: translate(-50%, -110%); display: none; font-variant-numeric: tabular-nums; }
|
||||
details { font-size: 14px; } summary { cursor: pointer; font-weight: 600; }
|
||||
details ul { margin: 8px 0 0; padding-left: 20px; color: var(--muted); }
|
||||
.alert { border-radius: var(--r); padding: var(--sp-sm) var(--sp-md); font-size: 14px; border: 1px solid; }
|
||||
.alert.warn { background: var(--warning-0); border-color: #ffe066; }
|
||||
.alert.info { background: var(--primary-0); border-color: #bfdbfe; }
|
||||
.alert b { display: block; margin-bottom: 2px; }
|
||||
.skeleton { background: linear-gradient(90deg, #f1f3f5, #e9ecef, #f1f3f5); background-size: 200% 100%;
|
||||
animation: sh 1.2s infinite; border-radius: 4px; }
|
||||
@keyframes sh { to { background-position: -200% 0; } }
|
||||
@media (max-width: 48em) {
|
||||
.kpi dd { font-size: 22px; } main { padding: var(--sp-sm); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="toolbar" id="controls" aria-label="Настройки прототипа">
|
||||
<fieldset><legend>Состояние</legend>
|
||||
<label><input type="radio" name="state" value="ahead" checked><span>Обгоняю</span></label>
|
||||
<label><input type="radio" name="state" value="behind"><span>Отстаю</span></label>
|
||||
<label><input type="radio" name="state" value="short"><span>< 1 года</span></label>
|
||||
<label><input type="radio" name="state" value="partial"><span>Неполные данные</span></label>
|
||||
<label><input type="radio" name="state" value="unavailable"><span>Недоступно</span></label>
|
||||
<label><input type="radio" name="state" value="loading"><span>Загрузка</span></label>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<main>
|
||||
<h1>Все счета</h1>
|
||||
<p class="sub">Брокерский счёт · ИИС · T-Bank</p>
|
||||
<nav class="tabs" aria-label="Разделы счёта">
|
||||
<span>Позиции</span><span>Концентрация</span><span>Деньги</span><span>Операции</span><span>События</span><span>Доход</span>
|
||||
<span class="active" aria-current="page">Результат</span>
|
||||
</nav>
|
||||
<div id="root" aria-live="polite"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ---------- Моковые данные ----------
|
||||
const DAY = 864e5;
|
||||
const d = (s) => new Date(s + 'T00:00:00Z');
|
||||
const NOW = d('2026-11-06');
|
||||
|
||||
// Детерминированный ПСЧ (генератор псевдослучайных чисел), чтобы ряд индекса был одинаковым при каждой загрузке
|
||||
function rng(seed) { return () => (seed = (seed * 16807) % 2147483647) / 2147483647; }
|
||||
|
||||
// Ряд индекса MCFTRR, IMOEX ≈ MCFTRR без дивидендов (~6%/год)
|
||||
function makeIndex(from, to, drift, seed) {
|
||||
const r = rng(seed), raw = []; let noise = 0;
|
||||
for (let t = from.getTime(); t <= to.getTime(); t += DAY) {
|
||||
const wd = new Date(t).getUTCDay(); if (wd === 0 || wd === 6) continue;
|
||||
noise += (r() - 0.5) * 0.026 + (t > d('2025-06-01').getTime() && t < d('2025-09-01').getTime() ? -0.0018 : 0);
|
||||
raw.push({ t, noise });
|
||||
}
|
||||
// Шум нормируется так, чтобы итоговый рост ряда точно совпал с drift: годовых для MCFTRR, drift − 6 % для IMOEX
|
||||
const end = raw[raw.length - 1], years = (end.t - raw[0].t) / (365 * DAY);
|
||||
return raw.map(({ t, noise: n }) => {
|
||||
const f = (t - raw[0].t) / (end.t - raw[0].t), wiggle = n - f * end.noise;
|
||||
return { t, mcftrr: 7000 * Math.pow(1 + drift, f * years) * Math.exp(wiggle), imoex: 3000 * Math.pow(1 + drift - 0.06, f * years) * Math.exp(wiggle) };
|
||||
});
|
||||
}
|
||||
|
||||
const SCENARIOS = {
|
||||
ahead: { start: '2023-03-14', flows: [['2023-03-14', 300000], ['2023-09-01', 150000], ['2024-02-10', 200000], ['2024-08-20', 100000], ['2025-03-05', 250000], ['2025-10-01', -120000], ['2026-04-15', 180000]], value: 1536400, drift: 0.13 },
|
||||
behind: { start: '2023-03-14', flows: [['2023-03-14', 300000], ['2023-09-01', 150000], ['2024-02-10', 200000], ['2024-08-20', 100000], ['2025-03-05', 250000], ['2025-10-01', -120000], ['2026-04-15', 180000]], value: 1172400, drift: 0.13 },
|
||||
short: { start: '2026-02-02', flows: [['2026-02-02', 400000], ['2026-05-12', 150000], ['2026-08-03', 100000]], value: 688900, drift: 0.11 },
|
||||
partial: { start: '2023-03-14', flows: [['2023-03-14', 300000], ['2023-09-01', 150000], ['2024-02-10', 200000], ['2024-08-20', 100000], ['2025-03-05', 250000], ['2025-10-01', -120000], ['2026-04-15', 180000]], value: 1536400, drift: 0.13,
|
||||
warnings: ['Счёт «Валютный» исключён: есть операции в USD. Результат посчитан по 2 из 3 счетов.'] },
|
||||
};
|
||||
|
||||
// ---------- Расчёт: та же методика, что предлагается для backend ----------
|
||||
// XIRR: найти r, при котором Σ CF_i·(1+r)^(-(t_i−t0)/365) = 0. Пополнение со знаком «−» (деньги ушли от инвестора), текущая стоимость со знаком «+».
|
||||
function npv(rate, cfs) { const t0 = cfs[0].t; return cfs.reduce((s, c) => s + c.amount / Math.pow(1 + rate, (c.t - t0) / (365 * DAY)), 0); }
|
||||
function xirr(cfs) {
|
||||
let lo = -0.9999, hi = 10; let flo = npv(lo, cfs), fhi = npv(hi, cfs);
|
||||
if (flo * fhi > 0) return null; // ponytail: бисекция; Ньютон + бисекция как запасной вариант в backend, если важна скорость
|
||||
for (let i = 0; i < 200; i++) { const m = (lo + hi) / 2, fm = npv(m, cfs); if (Math.abs(fm) < 1e-6) return m; (flo * fm < 0) ? (hi = m) : (lo = m, flo = fm); }
|
||||
return (lo + hi) / 2;
|
||||
}
|
||||
const periodReturn = (annual, days) => Math.pow(1 + annual, days / 365) - 1;
|
||||
|
||||
function compute(sc) {
|
||||
const idx = makeIndex(d(sc.start), NOW, sc.drift, 42);
|
||||
const flows = sc.flows.map(([s, a]) => ({ t: d(s).getTime(), amount: a }));
|
||||
// Теневой портфель: каждое пополнение покупает «паи» индекса по закрытию дня, вывод их продаёт
|
||||
let units = 0, unitsImoex = 0, invested = 0, fi = 0; const series = [];
|
||||
for (const p of idx) {
|
||||
while (fi < flows.length && flows[fi].t <= p.t) { invested += flows[fi].amount; units += flows[fi].amount / p.mcftrr; unitsImoex += flows[fi].amount / p.imoex; fi++; }
|
||||
series.push({ t: p.t, invested, shadow: units * p.mcftrr, imoex: unitsImoex * p.imoex });
|
||||
}
|
||||
const last = series[series.length - 1];
|
||||
const netInvested = flows.reduce((s, f) => s + f.amount, 0);
|
||||
const cfs = (end) => [...flows.map((f) => ({ t: f.t, amount: -f.amount })), { t: NOW.getTime(), amount: end }];
|
||||
const days = Math.round((NOW - d(sc.start)) / DAY);
|
||||
const you = xirr(cfs(sc.value)), shadow = xirr(cfs(last.shadow)), imoex = xirr(cfs(last.imoex));
|
||||
return { series, days, netInvested, value: sc.value, profit: sc.value - netInvested,
|
||||
shadowValue: last.shadow, imoexValue: last.imoex, you, shadow, imoex, short: days < 365, warnings: sc.warnings || [], start: sc.start };
|
||||
}
|
||||
|
||||
// ---------- Форматирование (ru-RU, как в shared/lib) ----------
|
||||
const rub = (v, sign) => (sign && v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(Math.round(v)).toLocaleString('ru-RU') + ' ₽';
|
||||
const pct = (v, sign) => (sign && v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v * 100).toLocaleString('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' %';
|
||||
const pp = (v) => (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v * 100).toLocaleString('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' п.п.';
|
||||
const date = (t) => new Date(t).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||
const tone = (v) => v > 0 ? 'pos' : v < 0 ? 'neg' : '';
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
|
||||
// Пока история короче года, показываем доходность за период, а не годовую
|
||||
function rate(m, key) {
|
||||
const a = m[key]; if (a == null) return { text: '—', label: '' };
|
||||
return m.short ? { v: periodReturn(a, m.days), text: pct(periodReturn(a, m.days), true), label: `за ${m.days} дн.` }
|
||||
: { v: a, text: pct(a, true), label: 'годовых' };
|
||||
}
|
||||
function delta(m) { const y = rate(m, 'you'), s = rate(m, 'shadow'); return { pp: y.v - s.v, rub: m.value - m.shadowValue }; }
|
||||
|
||||
// ---------- График (inline SVG, в production — Mantine AreaChart/LineChart) ----------
|
||||
function chart(m, { height = 260 } = {}) {
|
||||
const W = 960, H = height, P = { l: 70, r: 16, t: 12, b: 28 };
|
||||
const s = m.series, xs = (t) => P.l + (t - s[0].t) / (s[s.length - 1].t - s[0].t) * (W - P.l - P.r);
|
||||
const max = Math.max(...s.map((p) => Math.max(p.shadow, p.invested, p.imoex)), m.value) * 1.05;
|
||||
const ys = (v) => P.t + (1 - v / max) * (H - P.t - P.b);
|
||||
const line = (k) => s.map((p, i) => (i ? 'L' : 'M') + xs(p.t).toFixed(1) + ' ' + ys(p[k]).toFixed(1)).join('');
|
||||
const step = s.map((p, i) => (i ? `H${xs(p.t).toFixed(1)}V${ys(p.invested).toFixed(1)}` : `M${xs(p.t).toFixed(1)} ${ys(p.invested).toFixed(1)}`)).join('');
|
||||
const ticks = [0, .25, .5, .75, 1].map((f) => max * f);
|
||||
const years = [...new Set(s.map((p) => new Date(p.t).getUTCFullYear()))];
|
||||
const ex = xs(s[s.length - 1].t), ey = ys(m.value);
|
||||
return `<div class="chart-wrap">
|
||||
<div class="legend" aria-hidden="true">
|
||||
<span><i style="background:var(--c-invested)"></i>Вложено (нетто)</span>
|
||||
<span><i style="background:var(--c-index)"></i>Те же деньги в индексе Мосбиржи</span>
|
||||
<span><i class="dash"></i>Индекс Мосбиржи без дивидендов</span>
|
||||
<span><i style="background:var(--c-portfolio);height:10px;width:10px;border-radius:50%"></i>Ваш портфель сейчас</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Вложено ${rub(m.netInvested)}, те же деньги в индексе Мосбиржи стоили бы ${rub(m.shadowValue)}, ваш портфель ${rub(m.value)}">
|
||||
${ticks.map((v) => `<line x1="${P.l}" x2="${W - P.r}" y1="${ys(v)}" y2="${ys(v)}" stroke="#f1f3f5"/><text x="${P.l - 8}" y="${ys(v) + 4}" font-size="12" text-anchor="end" fill="#868e96">${(v / 1e6).toLocaleString('ru-RU', { maximumFractionDigits: 2 })} млн</text>`).join('')}
|
||||
${years.map((y) => { const t = Math.max(d(y + '-01-01').getTime(), s[0].t); return `<text x="${xs(t)}" y="${H - 8}" font-size="12" fill="#868e96">${y}</text>`; }).join('')}
|
||||
<path d="${line('shadow')} V${ys(0)} H${P.l} Z" fill="var(--c-index)" opacity=".08"/>
|
||||
<path d="${line('imoex')}" fill="none" stroke="var(--c-imoex)" stroke-width="1.5" stroke-dasharray="4 3"/>
|
||||
<path d="${line('shadow')}" fill="none" stroke="var(--c-index)" stroke-width="2"/>
|
||||
<path d="${step}" fill="none" stroke="var(--c-invested)" stroke-width="2"/>
|
||||
<line x1="${ex}" x2="${ex}" y1="${ys(m.shadowValue)}" y2="${ey}" stroke="var(--c-portfolio)" stroke-dasharray="2 2"/>
|
||||
<circle cx="${ex}" cy="${ey}" r="6" fill="var(--c-portfolio)" stroke="#fff" stroke-width="2"/>
|
||||
<rect class="hit" x="${P.l}" y="${P.t}" width="${W - P.l - P.r}" height="${H - P.t - P.b}" fill="transparent"/>
|
||||
</svg><div class="tooltip"></div></div>
|
||||
<p class="sub" style="margin:6px 0 0">Линии стоимости вашего портфеля в прошлом нет: история стоимости ещё не накоплена. Точка справа — текущая оценка.</p>`;
|
||||
}
|
||||
function bindTooltip(m) {
|
||||
document.querySelectorAll('.chart-wrap').forEach((w) => {
|
||||
const svg = w.querySelector('svg'), tip = w.querySelector('.tooltip'), s = m.series;
|
||||
svg.addEventListener('mousemove', (e) => {
|
||||
const r = svg.getBoundingClientRect(), f = Math.min(1, Math.max(0, ((e.clientX - r.left) / r.width * 960 - 70) / (960 - 86)));
|
||||
const p = s[Math.round(f * (s.length - 1))];
|
||||
tip.style.display = 'block'; tip.style.left = (e.clientX - r.left) + 'px'; tip.style.top = (e.clientY - r.top) + 'px';
|
||||
tip.innerHTML = `${date(p.t)}<br>Вложено: ${rub(p.invested)}<br>В индексе: ${rub(p.shadow)}<br>Без дивидендов: ${rub(p.imoex)}`;
|
||||
});
|
||||
svg.addEventListener('mouseleave', () => (tip.style.display = 'none'));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Общие блоки ----------
|
||||
const methodology = (m) => `<details class="paper"><summary>Как считали</summary><ul>
|
||||
<li>Период: с ${date(d(m.start))} (начало истории счёта) по ${date(NOW)}, ${m.days} дн.</li>
|
||||
<li>Учтены пополнения и выводы рублей. Купоны, дивиденды, комиссии и налоги уже входят в текущую стоимость портфеля.</li>
|
||||
<li><b>Доходность (XIRR)</b> учитывает, когда и сколько вы вносили. ${m.short ? 'История меньше года, поэтому процент показан за период, а не в годовых.' : 'Процент годовых.'}</li>
|
||||
<li><b>Те же деньги в индексе Мосбиржи</b>: каждое пополнение в тот же день «покупает» индекс Мосбиржи с учётом дивидендов после налогов (MCFTRR), каждый вывод его «продаёт».</li>
|
||||
<li>Привычный индекс Мосбиржи (IMOEX) не учитывает дивиденды, поэтому показан на графике только для справки и в сравнении не участвует.</li>
|
||||
<li>Цены индексов — MOEX ISS, закрытие дня. Это не налоговый расчёт.</li></ul></details>`;
|
||||
const warnings = (m) => m.warnings.map((w) => `<div class="alert warn" role="status"><b>Результат неполный</b>${esc(w)}</div>`).join('');
|
||||
const shortNote = (m) => m.short ? `<div class="alert info"><b>История меньше года</b>Проценты показаны за ${m.days} дн. без пересчёта в годовые: короткий срок даёт неустойчивые годовые значения.</div>` : '';
|
||||
const hint = (t) => `<span class="hint" tabindex="0" title="${t}" aria-label="${t}">?</span>`;
|
||||
|
||||
// ---------- Вариант A (выбран Change Owner 2026-11-07) ----------
|
||||
function variantA(m) {
|
||||
const y = rate(m, 'you'), s = rate(m, 'shadow'), dl = delta(m);
|
||||
return `<div class="stack">${warnings(m)}${shortNote(m)}
|
||||
<div class="grid">
|
||||
<dl class="paper kpi"><dt>Доходность ${hint('XIRR: ставка, при которой ваши пополнения и выводы дают текущую стоимость')}</dt><dd class="${tone(y.v)}">${y.text}</dd><dd class="note">${y.label}</dd></dl>
|
||||
<dl class="paper kpi"><dt>Прибыль</dt><dd class="${tone(m.profit)}">${rub(m.profit, true)}</dd><dd class="note">${rub(m.value)} сейчас · вложено ${rub(m.netInvested)}</dd></dl>
|
||||
<dl class="paper kpi"><dt>Те же деньги в индексе Мосбиржи ${hint('Если бы каждое пополнение в тот же день вкладывалось в индекс Мосбиржи с учётом дивидендов после налогов')}</dt><dd>${s.text}</dd><dd class="note">${s.label} · стоили бы ${rub(m.shadowValue)}</dd></dl>
|
||||
<dl class="paper kpi"><dt>${dl.pp >= 0 ? 'Опережение рынка' : 'Отставание от рынка'}</dt><dd class="${tone(dl.pp)}">${pp(dl.pp)}</dd><dd class="note"><span class="badge ${tone(dl.rub)}">${rub(dl.rub, true)}</span> к индексу</dd></dl>
|
||||
</div>
|
||||
<section class="paper"><h2 class="section-title">Ваш портфель против индекса</h2>${chart(m)}</section>
|
||||
${methodology(m)}</div>`;
|
||||
}
|
||||
|
||||
// ---------- Состояния без данных ----------
|
||||
const unavailable = () => `<div class="stack"><div class="paper" style="text-align:center;padding:var(--sp-xl)">
|
||||
<div class="section-title">Доходность недоступна</div>
|
||||
<p class="sub" style="max-width:520px;margin:8px auto">Для счёта «ИИС» неизвестна дата открытия. Без полной истории пополнений нельзя честно посчитать доходность: показали бы неверное число вместо нуля.</p>
|
||||
<span class="badge muted">Причина: начало истории неизвестно</span>
|
||||
<p class="sub" style="margin-top:var(--sp-md)">Другие возможные причины: операции не в рублях · ввод или вывод ценных бумаг без денежной оценки · расчёт не сходится · история индекса MOEX временно недоступна.</p>
|
||||
</div></div>`;
|
||||
const loading = () => `<div class="stack"><div class="grid">${'<div class="paper"><div class="skeleton" style="height:14px;width:60%"></div><div class="skeleton" style="height:30px;width:80%;margin-top:8px"></div></div>'.repeat(4)}</div>
|
||||
<div class="paper"><div class="skeleton" style="height:240px"></div></div></div>`;
|
||||
|
||||
// ---------- Отрисовка ----------
|
||||
const form = document.getElementById('controls'), root = document.getElementById('root');
|
||||
const cache = {};
|
||||
function render() {
|
||||
const state = form.state.value;
|
||||
if (state === 'unavailable') return (root.innerHTML = unavailable());
|
||||
if (state === 'loading') return (root.innerHTML = loading());
|
||||
const m = cache[state] ||= compute(SCENARIOS[state]);
|
||||
root.innerHTML = variantA(m);
|
||||
bindTooltip(m);
|
||||
}
|
||||
form.addEventListener('change', render);
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
# QA Analysis
|
||||
|
||||
Issue: WealthLedger/main#54
|
||||
|
||||
## Verification Scope
|
||||
|
||||
**In:**
|
||||
- все Requirements и Scenarios `portfolio/investment-performance` и
|
||||
`instruments/market-index-history`;
|
||||
- контракт `investment-performance.openapi.yaml`;
|
||||
- семантика ошибок System Analysis.
|
||||
|
||||
**Out:**
|
||||
- TWR, периоды, валютная конвертация;
|
||||
- корректность самой синхронизации T-Bank (покрыта её спецификациями);
|
||||
- точность значений MCFTRR у источника.
|
||||
|
||||
## Coverage Matrix
|
||||
|
||||
| Requirement / Scenario | Проверка на уровне системы |
|
||||
| --- | --- |
|
||||
| Результат счёта: рублёвый счёт с известным началом | API: ответ 200 со всеми полями; значения совпадают с эталоном (ниже) |
|
||||
| Результат счёта: счёт недоступен через подключение | API: чужой `accountId` → 404, в теле нет финансовых полей |
|
||||
| Внешние потоки: пополнения, вывод, дивиденды | Эталонный счёт A: `netInvested = 130000`, `profit = 10000` |
|
||||
| Внешние потоки: кандидат на перевод между счетами в `all` | Счета B и C с равными суммами в пределах 5 минут исключаются с `UNCONFIRMED_TRANSFER`, а не объявляются внутренней парой |
|
||||
| Внешние потоки: перевод на счёт вне результата | Счёт B исключён (USD), его пополнение от C → вывод у C в `all` |
|
||||
| Доходность: период больше года | Счёт D: 100 000 → 121 000 за 730 дн. → `return.value = 0.1`, `basis = annual` |
|
||||
| Доходность: меньше года | Счёт E, 200 дн.: `basis = period`; в UI нет слова «годовых» ни в одном значении |
|
||||
| Доходность: нет решения | Счёт F (текущая стоимость 0 после пополнений, без выводов): `NO_SOLUTION`, `profit` доступен, `outperformance = null` |
|
||||
| Доходность: несколько решений | Набор потоков с двумя корнями XIRR: `AMBIGUOUS_RETURN`, прибыль доступна, `outperformance = null` |
|
||||
| Индекс: пополнение и рост индекса | Подменённая история (1 000 → 1 100) или ручной расчёт по реальным закрытиям: `benchmark.value = 110000`, `outperformance.money = 5000` |
|
||||
| Индекс: поток в выходной | Пополнение в субботу → используется закрытие пятницы (проверяется по `series` в день потока) |
|
||||
| Индекс: отставание | Счёт со стоимостью ниже теневой: отрицательные `percentagePoints` и `money`, в UI «Отставание от рынка» |
|
||||
| Справочный индекс недоступен | IMOEX нет, MCFTRR есть: `reference.unavailableReason` задан, `benchmark` и `outperformance` есть, в UI нет пунктирной линии |
|
||||
| Недоступно: неизвестная дата открытия | Счёт с `historyBoundary = unknown`: `data: null`, `HISTORY_START_UNKNOWN`; в UI «Доходность недоступна», нет нулей |
|
||||
| Недоступно: USD в `all` | 3 счёта, один с USD: `completeness = incomplete`, `excludedAccounts` содержит счёт и `NON_RUB_ACTIVITY`; в UI предупреждение |
|
||||
| Недоступно: даты оценок в `all` различаются | Последние оценки A и B в разные московские дни → `data: null`, `ACCOUNT_VALUATION_DATES_DIFFER`; стоимость не складывается |
|
||||
| Недоступно: индекс недоступен | MOEX заблокирован и истории нет: `profit` есть, `benchmark.unavailableReason = INDEX_HISTORY_UNAVAILABLE`, `series = []` |
|
||||
| Недоступно: остальные причины | Перевод бумаг без пары → `UNVALUED_SECURITIES_TRANSFER`; позиция без цены → `PORTFOLIO_VALUATION_INCOMPLETE`; незавершённая синхронизация → `OPERATIONS_HISTORY_INCOMPLETE` |
|
||||
| Вкладка: обгоняю, меньше года, неполный, недоступный, методика | UI по соответствующим данным; формулировки и тоны как в UI/UX Package; «Как считали» содержит все пункты спецификации |
|
||||
| Вкладка: загрузка, ошибка не ломает другие вкладки | Задержка ответа → skeleton без чисел; 500 на `/performance` → ошибка только во вкладке, остальные вкладки работают |
|
||||
| График: наведение, отсутствие прошлой стоимости | Наведение и фокус показывают дату и 3 значения; есть пояснение под графиком; `aria-label` с итогами |
|
||||
| История индексов: первое получение, выходной | После первого чтения есть закрытия торговых дней без дубликатов; повторное чтение не меняет число записей; субботних дат нет |
|
||||
| История индексов: источник недоступен, история есть / нет | Блокировка `iss.moex.com` после первичной загрузки → результат полный; на чистой БД → `INDEX_HISTORY_UNAVAILABLE`, остальные вкладки работают |
|
||||
|
||||
**Эталон.** Для счетов A–F и реальных данных пользователя XIRR
|
||||
пересчитывается независимо (LibreOffice `XIRR` по тем же потокам и
|
||||
`currentValue` в дату `to`). Допуск — 0,1 п.п., по Success Criteria.
|
||||
|
||||
## Cross-system Verification
|
||||
|
||||
- **Контракт.** Ответы обоих маршрутов валидируются по
|
||||
`investment-performance.openapi.yaml`: required, nullable, enum, шаблон
|
||||
`Decimal`. Неизвестное значение `UnavailableReason`, подставленное в ответ
|
||||
через прокси или мок, UI показывает общей недоступностью без падения.
|
||||
- **Ошибки доступа.** 401 без сессии; 503 `TBANK_NOT_CONNECTED` без
|
||||
подключения; 404 для счёта не из подключения. `all` без подключения тоже
|
||||
503.
|
||||
- **Таймаут MOEX.** Ответ ISS задерживается больше 10 с: результат приходит
|
||||
с сохранённой историей или с `INDEX_HISTORY_UNAVAILABLE`. Время ответа —
|
||||
не больше ~10 с плюс время расчёта, без бесконечного ожидания.
|
||||
- **Параллельные чтения.** Два одновременных GET на чистой БД дают одну
|
||||
серию запросов к ISS и одинаковые ответы.
|
||||
- **Частота догрузки.** Повторное чтение в течение часа после сбоя не
|
||||
обращается к ISS (проверяется по логам).
|
||||
- **Порядок данных.** Пополнение, исполненное после `fetchedAt` Portfolio, в
|
||||
потоки не входит.
|
||||
|
||||
## Risk-based Priorities
|
||||
|
||||
1. **Критично — деньги.** Значения прибыли, XIRR и опережения совпадают с
|
||||
эталоном. Ни один недоступный случай не показывает число.
|
||||
2. **Критично — доступ.** Нет утечки чужих счетов в `all` и `meta.unavailable`.
|
||||
3. **Высокий риск — переводы в `all`** (эвристика парности) и счета с
|
||||
реальной историей пользователя.
|
||||
4. **Высокий риск — меньше года.** Ни одного «годовых».
|
||||
5. **Средний риск — устойчивость к MOEX**, график и a11y.
|
||||
|
||||
## Test Data and Environment Needs
|
||||
|
||||
- Пользователь с подключением T-Bank и наборами счетов:
|
||||
- A — пополнения, вывод, дивиденды;
|
||||
- B — USD;
|
||||
- C — перевод с B и C;
|
||||
- D — 730 дн.;
|
||||
- E — 200 дн.;
|
||||
- F — нет решения;
|
||||
- G — `historyBoundary = unknown`;
|
||||
- H — перевод бумаг без пары;
|
||||
- I — позиция без цены.
|
||||
|
||||
Если sandbox T-Bank такие данные не даёт, их записывают напрямую в
|
||||
одноразовую SQLite-копию.
|
||||
- Возможность блокировать `iss.moex.com` (сеть или прокси) и задерживать
|
||||
его ответы.
|
||||
- Чистая БД и БД с предзагруженной историей индексов.
|
||||
- Реальные данные Change Owner для финальной приёмки.
|
||||
|
||||
## Manual / Exploratory Scope
|
||||
|
||||
- Реальные счета пользователя:
|
||||
- правдоподобие результата;
|
||||
- парность переводов между своими счетами;
|
||||
- какие счета исключаются и почему.
|
||||
- Вёрстка 390px и desktop; клавиатурная навигация (вкладка, hint, «Как
|
||||
считали», график); экранный диктор читает итоги графика.
|
||||
- Переключение счёт ↔ `all` и обратно: нет устаревших данных другого счёта.
|
||||
|
||||
## Automation Candidates
|
||||
|
||||
- Black-box API-проверки контракта и сценариев A–I: deferred until QA repo
|
||||
exists.
|
||||
- E2E вкладки «Результат» (состояния ready, unavailable, incomplete,
|
||||
error): deferred until QA repo exists.
|
||||
|
||||
## Observability Verification
|
||||
|
||||
- `integration.moex.index_history.succeeded` / `.failed` появляются при
|
||||
догрузке и сбое; в них есть secid, диапазон, pages, durationMs и нет
|
||||
данных пользователя.
|
||||
- В логах расчёта нет сумм и идентификаторов операций.
|
||||
|
||||
## Regression Impact
|
||||
|
||||
- Вкладки счёта: позиции, концентрация, деньги, операции, события, доход —
|
||||
переключение и загрузка не меняются.
|
||||
- Запись истории оценки Portfolio после выноса признака полной оценки
|
||||
(`portfolio-value-history`): полные оценки по-прежнему создают точки,
|
||||
неполные — нет.
|
||||
- Существующие запросы к MOEX по облигациям.
|
||||
- `openapi:check` обоих репозиториев.
|
||||
|
||||
## Gaps / Blockers
|
||||
|
||||
- **Подтверждение переводов** отсутствует в текущем контракте T-Bank.
|
||||
Routing: System Analysis при появлении нового признака связи; до этого
|
||||
кандидаты исключаются с явной причиной, не выдавая сомнительный процент.
|
||||
- **Sandbox T-Bank** может не воспроизвести все наборы. Routing: Test Data —
|
||||
наполнение одноразовой SQLite-копии; не блокирует.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
## Purpose
|
||||
Определяет получение и локальное хранение дневной истории индексов Московской биржи, нужных для сравнения результата инвестирования, и поведение при недоступности источника.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Дневная история индексов Мосбиржи
|
||||
Система SHALL получать из публичных исторических данных Московской биржи и хранить локально значения закрытия торговых дней двух индексов: индекса с дивидендами после налогов (MCFTRR) и ценового индекса (IMOEX). Значение MUST храниться точно, с датой торгового дня по Europe/Moscow. Повторное получение того же дня MUST NOT создавать дубликат. Отсутствие торгов в день MUST NOT заполняться выдуманным значением.
|
||||
|
||||
#### Scenario: Первое получение истории
|
||||
- **WHEN** результату нужен период, не покрытый сохранённой историей индекса
|
||||
- **THEN** система получает недостающие торговые дни и сохраняет их без дубликатов
|
||||
|
||||
#### Scenario: Выходной день
|
||||
- **WHEN** в календарный день не было торгов
|
||||
- **THEN** для этого дня не сохраняется никакое значение индекса
|
||||
|
||||
### Requirement: Свежесть и недоступность истории индексов
|
||||
Сохранённая история MUST переиспользоваться без повторного получения уже сохранённых дней. История MUST дополняться до последнего доступного торгового дня не реже раза в сутки при запросе результата. Недоступность или ошибка источника MUST NOT удалять сохранённую историю и MUST NOT задерживать или ломать другие ответы системы. Если сохранённая история не покрывает нужный период, потребитель MUST получить явную недоступность индекса, а не неполные значения, выдаваемые за полные.
|
||||
|
||||
#### Scenario: Источник недоступен, история сохранена
|
||||
- **WHEN** источник Московской биржи недоступен, а сохранённая история покрывает период результата до предыдущего торгового дня
|
||||
- **THEN** результат рассчитывается по сохранённой истории
|
||||
|
||||
#### Scenario: Источник недоступен, истории нет
|
||||
- **WHEN** источник Московской биржи недоступен и история индекса за период не сохранена
|
||||
- **THEN** сравнение с индексом недоступно с причиной «история индекса недоступна», а остальные разделы счёта работают без изменений
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
## Purpose
|
||||
Определяет результат инвестирования брокерского счёта и режима «Все счета» за всё время: прибыль, доходность с учётом сумм и дат пополнений и сравнение с теми же деньгами в индексе Мосбиржи.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Результат инвестирования выбранного счёта
|
||||
Система SHALL предоставлять аутентифицированному User результат инвестирования за всё время для открытого поддерживаемого Brokerage Account, доступного через текущий T-Bank connection User, и для режима «Все счета» (`all`). История операций MUST быть полной с известной даты начала истории счёта; период результата MUST начинаться с даты первого внешнего потока и заканчиваться датой последней полной оценки Portfolio. Даты MUST определяться календарными днями по Europe/Moscow. Результат MUST рассчитываться только в RUB без валютной конвертации.
|
||||
|
||||
#### Scenario: Рублёвый счёт с известным началом истории
|
||||
- **WHEN** User открывает результат рублёвого счёта с известным началом истории, полной историей операций и полной текущей оценкой Portfolio
|
||||
- **THEN** система возвращает период результата, чистые внешние вложения, текущую стоимость, прибыль, доходность, результат тех же денег в индексе и опережение
|
||||
|
||||
#### Scenario: Счёт недоступен через текущее подключение
|
||||
- **WHEN** User запрашивает результат счёта, недоступного через текущий T-Bank connection
|
||||
- **THEN** система не раскрывает никаких значений результата этого счёта
|
||||
|
||||
### Requirement: Внешние денежные потоки
|
||||
Внешним денежным потоком MUST считаться только пополнение счёта рублями (положительный поток) и вывод рублей со счёта (отрицательный поток) на дату исполнения. Сделки, купоны, дивиденды, погашения, амортизация, налоги и комиссии MUST NOT считаться внешними потоками: их влияние входит в текущую стоимость. В режиме `all` переводы денег и бумаг между счетами, входящими в результат, MUST NOT считаться внешними потоками. Чистые внешние вложения MUST равняться точной сумме внешних потоков периода. Прибыль MUST равняться точной разности текущей стоимости и чистых внешних вложений.
|
||||
|
||||
#### Scenario: Пополнения, вывод и дивиденды
|
||||
- **WHEN** на счёт внесено 100 000 RUB и 50 000 RUB, выведено 20 000 RUB, получены дивиденды 3 000 RUB, а текущая стоимость Portfolio 140 000 RUB
|
||||
- **THEN** чистые внешние вложения равны 130 000 RUB, прибыль равна 10 000 RUB, а дивиденды не учитываются как отдельный поток
|
||||
|
||||
#### Scenario: Подтверждённый перевод между счетами в режиме «Все счета»
|
||||
- **WHEN** в режиме `all` источник однозначно подтверждает перевод 50 000 RUB со счёта A на счёт B, и оба счёта входят в результат
|
||||
- **THEN** перевод не меняет чистые внешние вложения и доходность режима `all`
|
||||
|
||||
#### Scenario: Перевод на счёт вне результата
|
||||
- **WHEN** в режиме `all` деньги переведены на счёт, не входящий в результат
|
||||
- **THEN** перевод учитывается как вывод в дату исполнения
|
||||
|
||||
#### Scenario: Совпадение операций не доказывает перевод
|
||||
- **WHEN** в режиме `all` вывод на одном счёте и пополнение другого совпадают по сумме и времени, но источник не подтверждает связь между ними
|
||||
- **THEN** система не объявляет их внутренним переводом, исключает затронутые счета из рассчитанной части результата и показывает причину неполноты; если не осталось счетов для расчёта, результат недоступен
|
||||
|
||||
### Requirement: Доходность с учётом сумм и дат потоков
|
||||
Доходность MUST быть годовой ставкой r, при которой сумма всех внешних потоков, дисконтированных по фактическому числу дней от начала периода по формуле `(1 + r)^(дни / 365)`, и текущей стоимости равна нулю (XIRR). Пополнения MUST входить в расчёт как вложение User, выводы и текущая стоимость — как возврат. Если продолжительность периода меньше 365 дней, система MUST возвращать и показывать доходность за период `(1 + r)^(дни / 365) − 1` с явным числом дней и MUST NOT показывать годовое значение. Если единственное решение не найдено, доходность MUST быть недоступной с причиной «расчёт не сходится», а не нулём.
|
||||
|
||||
#### Scenario: Период больше года
|
||||
- **WHEN** 100 000 RUB внесены ровно 730 дней назад, других потоков не было, а текущая стоимость 121 000 RUB
|
||||
- **THEN** доходность равна 10,0 % годовых
|
||||
|
||||
#### Scenario: Период меньше года
|
||||
- **WHEN** период результата составляет 200 дней
|
||||
- **THEN** доходность показывается как процент за 200 дней, и ни одно значение результата не подписано как «годовых»
|
||||
|
||||
#### Scenario: Первое пополнение позже открытия счёта
|
||||
- **WHEN** счёт открыт 300 дней назад, 100 000 RUB внесены 100 дней назад, а текущая стоимость 110 000 RUB
|
||||
- **THEN** период равен 100 дням, а доходность показывается как +10,0 % за 100 дней
|
||||
|
||||
#### Scenario: Нет решения
|
||||
- **WHEN** набор потоков и текущая стоимость не дают ни одной ставки, обнуляющей сумму
|
||||
- **THEN** доходность и опережение недоступны с причиной «расчёт не сходится», а прибыль остаётся доступной
|
||||
|
||||
#### Scenario: Несколько ставок
|
||||
- **WHEN** набор потоков допускает более одной ставки доходности
|
||||
- **THEN** доходность и опережение недоступны с причиной «расчёт неоднозначен», а прибыль остаётся доступной
|
||||
|
||||
### Requirement: Те же деньги в индексе Мосбиржи
|
||||
Система SHALL рассчитывать альтернативный результат: каждый внешний поток в ту же дату применяется к индексу Мосбиржи с учётом дивидендов после налогов (MCFTRR) по значению закрытия этого дня. Если в дату потока не было торгов, MUST использоваться последнее предшествующее закрытие. Стоимость альтернативы MUST равняться остатку условных паёв индекса, умноженному на последнее закрытие не позже конца периода. Доходность альтернативы MUST считаться той же методикой и за тот же период, что и доходность User. Опережение MUST равняться разнице доходностей User и альтернативы в процентных пунктах и разнице текущей стоимости и стоимости альтернативы в RUB. Отрицательное опережение MUST называться отставанием.
|
||||
|
||||
#### Scenario: Пополнение и рост индекса
|
||||
- **WHEN** 100 000 RUB внесены в день, когда закрытие индекса было 1 000, к концу периода закрытие индекса 1 100, а текущая стоимость Portfolio 115 000 RUB
|
||||
- **THEN** стоимость тех же денег в индексе равна 110 000 RUB, а опережение составляет +5 000 RUB
|
||||
|
||||
#### Scenario: Поток в неторговый день
|
||||
- **WHEN** пополнение исполнено в субботу
|
||||
- **THEN** для него используется закрытие индекса последнего торгового дня до этой субботы
|
||||
|
||||
#### Scenario: Отставание от индекса
|
||||
- **WHEN** доходность User ниже доходности тех же денег в индексе
|
||||
- **THEN** результат показывается как отставание с отрицательными значениями в процентных пунктах и RUB
|
||||
|
||||
### Requirement: Справочный индекс без дивидендов
|
||||
Система SHALL дополнительно рассчитывать тот же альтернативный результат по ценовому индексу Мосбиржи (IMOEX) только для справки. Справочный индекс MUST NOT участвовать в опережении и отставании. Его недоступность MUST NOT делать недоступным основной результат.
|
||||
|
||||
#### Scenario: Справочный индекс недоступен
|
||||
- **WHEN** история ценового индекса недоступна, а история индекса с дивидендами доступна
|
||||
- **THEN** основной результат и опережение показываются, а справочная линия отсутствует с пометкой о недоступности
|
||||
|
||||
### Requirement: Недоступный и неполный результат
|
||||
Система MUST NOT возвращать числовой результат, выдаваемый за доходность, если его нельзя рассчитать честно. Для одного счёта результат MUST быть недоступным с машиночитаемой причиной, если:
|
||||
- начало истории операций неизвестно;
|
||||
- есть поток или позиция не в RUB;
|
||||
- есть ввод или вывод ценных бумаг без денежной оценки, а в режиме `all` — неподтверждённый кандидат на перевод между своими счетами;
|
||||
- текущая оценка Portfolio неполная;
|
||||
- история операций не получена полностью.
|
||||
|
||||
Если история индекса с дивидендами не покрывает период, доступными MUST оставаться только чистые вложения, текущая стоимость и прибыль, а доходность альтернативы и опережение MUST быть недоступны с причиной. В режиме `all` счёт с причиной недоступности MUST исключаться из результата, а результат MUST помечаться как неполный с перечнем исключённых счетов и причин. Если не осталось ни одного счёта, результат режима `all` MUST быть недоступным.
|
||||
|
||||
#### Scenario: Неизвестная дата открытия
|
||||
- **WHEN** у счёта неизвестно начало истории операций
|
||||
- **THEN** результат счёта недоступен с причиной «начало истории неизвестно», и ни одно значение не показывается как ноль
|
||||
|
||||
#### Scenario: Счёт с операциями в USD в режиме «Все счета»
|
||||
- **WHEN** в режиме `all` один из трёх счетов содержит пополнение в USD
|
||||
- **THEN** результат рассчитывается по двум оставшимся счетам и помечается как неполный с указанием исключённого счёта и причины «операции не в рублях»
|
||||
|
||||
#### Scenario: Счета оценены в разные московские дни
|
||||
- **WHEN** в режиме `all` последние полные оценки включённых счетов относятся к разным московским календарным дням
|
||||
- **THEN** сводный результат недоступен с причиной «оценки счетов на разные даты»; последние стоимости не складываются как одновременные
|
||||
|
||||
#### Scenario: Индекс недоступен
|
||||
- **WHEN** история индекса с дивидендами не получена за период результата
|
||||
- **THEN** прибыль показывается, а результат тех же денег в индексе и опережение недоступны с причиной «история индекса недоступна»
|
||||
|
||||
### Requirement: Вкладка «Результат» на странице счёта
|
||||
Страница брокерского счёта и режима «Все счета» SHALL содержать вкладку «Результат», которая показывает:
|
||||
- доходность с подписью «годовых» либо «за N дн.»;
|
||||
- прибыль в RUB с текущей стоимостью и чистыми вложениями;
|
||||
- «Те же деньги в индексе Мосбиржи» с доходностью и стоимостью;
|
||||
- «Опережение рынка» либо «Отставание от рынка» в процентных пунктах и RUB;
|
||||
- график;
|
||||
- раскрываемый блок «Как считали».
|
||||
|
||||
Положительные значения MUST выделяться положительным тоном, отрицательные — отрицательным, и смысл MUST NOT передаваться только цветом (знак «+» или «−» и текст обязательны). Технические коды индексов MUST NOT использоваться как основные подписи и MAY приводиться только в блоке «Как считали». Пока результат загружается, вкладка MUST показывать skeleton без чисел. Ошибка загрузки результата MUST NOT влиять на другие вкладки счёта.
|
||||
|
||||
#### Scenario: Пользователь обгоняет рынок
|
||||
- **WHEN** доходность User выше доходности тех же денег в индексе
|
||||
- **THEN** вкладка показывает «Опережение рынка» со значениями «+N п.п.» и «+N ₽» в положительном тоне
|
||||
|
||||
#### Scenario: История меньше года
|
||||
- **WHEN** период результата короче 365 дней
|
||||
- **THEN** вкладка показывает пояснение, что проценты даны за период без пересчёта в годовые, и подписывает каждый процент «за N дн.»
|
||||
|
||||
#### Scenario: Неполный результат
|
||||
- **WHEN** результат режима `all` неполный
|
||||
- **THEN** над показателями отображается предупреждение «Результат неполный» с исключёнными счетами и причинами
|
||||
|
||||
#### Scenario: Недоступный результат
|
||||
- **WHEN** результат недоступен
|
||||
- **THEN** вкладка вместо показателей показывает «Доходность недоступна» и понятное описание причины, без нулевых значений
|
||||
|
||||
#### Scenario: Пояснение методики
|
||||
- **WHEN** User раскрывает «Как считали»
|
||||
- **THEN** видны период, учтённые потоки, смысл доходности, правило «тех же денег в индексе», пометка о справочном характере индекса без дивидендов, источник данных индексов и оговорка, что это не налоговый расчёт
|
||||
|
||||
### Requirement: График результата
|
||||
График вкладки «Результат» SHALL показывать за период результата:
|
||||
- ступенчатую линию чистых внешних вложений;
|
||||
- линию стоимости тех же денег в индексе Мосбиржи по закрытиям торговых дней;
|
||||
- справочную линию тех же денег в индексе без дивидендов;
|
||||
- отдельную отметку текущей стоимости Portfolio на конец периода.
|
||||
|
||||
График MUST NOT показывать прошлую стоимость Portfolio User и MUST сообщать, что её линия отсутствует. Значения при наведении или фокусе MUST показывать дату и все значения линий на эту дату. У графика MUST быть текстовая альтернатива с итоговыми значениями.
|
||||
|
||||
#### Scenario: Наведение на дату графика
|
||||
- **WHEN** User наводит курсор на дату графика
|
||||
- **THEN** показываются дата, чистые вложения, стоимость в индексе и стоимость в индексе без дивидендов на эту дату
|
||||
|
||||
#### Scenario: Отсутствие прошлой стоимости Portfolio
|
||||
- **WHEN** график отображается
|
||||
- **THEN** стоимость Portfolio показана только отметкой на конце периода, а под графиком есть пояснение об отсутствии линии прошлой стоимости
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
# System Analysis
|
||||
|
||||
Issue: WealthLedger/main#54
|
||||
|
||||
## Requirement Coverage
|
||||
|
||||
| Нормативная область | Поток и контракт |
|
||||
| --- | --- |
|
||||
| `investment-performance`: результат счёта и `all`, доступ | [performance-read.mmd](sequences/performance-read.mmd), [contract](contracts/investment-performance.openapi.yaml) `Performance`, `PerformanceResource` |
|
||||
| `investment-performance`: внешние потоки, переводы в `all` | [Маппинг операций](#маппинг-операций-t-bank), [Переводы в all](#переводы-между-счетами-в-all) |
|
||||
| `investment-performance`: доходность, basis annual/period, нет решения | [Расчёт](#расчёт), `ReturnValue` |
|
||||
| `investment-performance`: те же деньги в индексе, неторговый день, отставание | [Расчёт](#расчёт), `IndexResult`, `Outperformance` |
|
||||
| `investment-performance`: справочный индекс | `reference`, `SeriesPoint.referenceValue` |
|
||||
| `investment-performance`: недоступный и неполный результат | [Причины недоступности](#причины-недоступности), `UnavailableReason`, `excludedAccounts`, `meta.unavailable` |
|
||||
| `investment-performance`: вкладка и график | `series`, `period.days`, `return.basis`; поведение UI — в Design |
|
||||
| `market-index-history`: хранение, свежесть, недоступность | [index-refresh.mmd](sequences/index-refresh.mmd), [Индексы MOEX](#индексы-moex) |
|
||||
|
||||
## Systems and Responsibilities
|
||||
|
||||
- **Frontend**: вкладка «Результат». Делает один GET на страницу счёта или
|
||||
`all`, сам ничего финансово не рассчитывает. Отличает загрузку,
|
||||
HTTP-ошибку, `data: null` (недоступно) и `completeness: incomplete`.
|
||||
Форматирует доли в проценты и п.п., подпись берёт из `return.basis` и
|
||||
`period.days`.
|
||||
- **Backend**: единственный владелец расчёта. Проверяет доступ, отбирает
|
||||
счета для расчёта, выделяет внешние потоки из сохранённых операций, берёт
|
||||
последнюю полную оценку Portfolio, считает XIRR и теневой портфель, хранит и
|
||||
догружает историю индексов.
|
||||
- **Сохранённые данные T-Bank** (существующая синхронизация): операции,
|
||||
граница истории и покрытие счёта, последний Portfolio. Новых вызовов T-Bank
|
||||
нет.
|
||||
- **MOEX ISS**: публичный источник дневных закрытий MCFTRR и IMOEX.
|
||||
Вызывается только backend.
|
||||
|
||||
## Interaction Flows
|
||||
|
||||
- [performance-read.mmd](sequences/performance-read.mmd): чтение результата
|
||||
со всеми ветками доступа, недоступности и сбоя индекса.
|
||||
- [index-refresh.mmd](sequences/index-refresh.mmd): догрузка истории индекса
|
||||
при чтении, пагинация, сбой источника.
|
||||
|
||||
Фоновой задачи нет: история индекса догружается при чтении результата.
|
||||
Однодневная свежесть (требование `market-index-history`) обеспечивается тем,
|
||||
что каждое чтение проверяет покрытие до `period.to`.
|
||||
|
||||
## Contracts
|
||||
|
||||
| Producer → Consumer | Операция | Схема | Совместимость |
|
||||
| --- | --- | --- | --- |
|
||||
| Backend → Frontend | `GET /api/v1/brokerage-accounts/{accountId}/performance`, `GET /api/v1/brokerage-accounts/all/performance` (HTTP/JSON, сессия как у остальных `/api/v1`) | [investment-performance.openapi.yaml](contracts/investment-performance.openapi.yaml) | Новые маршруты, аддитивно. Enum `UnavailableReason` расширяемый: неизвестное значение клиент показывает как общую недоступность |
|
||||
| MOEX ISS → Backend | `GET https://iss.moex.com/iss/history/engines/stock/markets/index/securities/{MCFTRR\|IMOEX}.json?from&till&start&iss.meta=off&history.columns=TRADEDATE,CLOSE` | Блок `history` (TRADEDATE `YYYY-MM-DD`, CLOSE число), `history.cursor` (INDEX, TOTAL, PAGESIZE = 100) | Внешний, без версии. Неожиданная форма ответа считается сбоем источника, частичный разбор запрещён |
|
||||
|
||||
- HTTP-ошибки — существующий `ProblemDetailsDto` и коды `UNAUTHORIZED`,
|
||||
`NOT_FOUND`, `TBANK_NOT_CONNECTED`, `TBANK_UNAVAILABLE`,
|
||||
`INTERNAL_SERVER_ERROR`. Новых problem codes нет.
|
||||
- Недоступность расчёта не является HTTP-ошибкой: ответ 200, а причина в
|
||||
`UnavailableReason`. `MOEX_UNAVAILABLE` в HTTP-ответе не возвращается:
|
||||
сбой индекса превращается в `INDEX_HISTORY_UNAVAILABLE` внутри
|
||||
`benchmark`/`reference`.
|
||||
- Числа: деньги — `MoneyDto` (RUB, десятичная строка, округление до копеек
|
||||
half away from zero, как общий decimal helper); доли (`Rate`) — десятичная строка до 6 знаков, `0.151` = 15,1 %;
|
||||
точки `series` — десятичные RUB до копеек.
|
||||
- Идемпотентность: GET без побочных эффектов, кроме догрузки истории индекса.
|
||||
Её повтор безопасен (upsert по индексу и торговому дню).
|
||||
|
||||
## Data and State Semantics
|
||||
|
||||
### Источники правды
|
||||
|
||||
| Данные | Владелец | Используемое значение |
|
||||
| --- | --- | --- |
|
||||
| Внешние потоки | Сохранённые операции T-Bank (EXECUTED) | `type`, `occurredAt`, `payment` (знак и валюта) |
|
||||
| Начало истории | Граница истории счёта | `historyBoundary = known` → `historyFrom` |
|
||||
| Полнота операций | Покрытие операций счёта | `historyState = complete` от `historyFrom` до времени оценки |
|
||||
| Текущая стоимость | Последний сохранённый Portfolio | `totalsByCurrency[RUB]`, `fetchedAt` |
|
||||
| Закрытия индексов | Новое локальное хранилище истории индексов | (индекс, торговый день) → точное CLOSE |
|
||||
|
||||
### Маппинг операций T-Bank
|
||||
|
||||
| Тип `OPERATION_TYPE_*` | Роль |
|
||||
| --- | --- |
|
||||
| `INPUT`, `INPUT_ACQUIRING`, `INPUT_SWIFT`, `INP_MULTI` | Внешнее пополнение: `+|payment|` |
|
||||
| `OUTPUT`, `OUTPUT_ACQUIRING`, `OUTPUT_SWIFT`, `OUT_MULTI` | Внешний вывод: `−|payment|` |
|
||||
| `INPUT_SECURITIES`, `OUTPUT_SECURITIES`, `TRANS_IIS_BS`, `TRANS_BS_BS` | Перевод бумаг: у отдельного счёта всегда `UNVALUED_SECURITIES_TRANSFER`; в `all` — см. ниже |
|
||||
| Сделки, купоны, дивиденды, погашения, амортизация, налоги, комиссии (включая `OUT_FEE`, `OUTPUT_PENALTY`), прочие | Внутренние, в потоки не входят |
|
||||
|
||||
- Поток в валюте, отличной от RUB, даёт счёту `NON_RUB_ACTIVITY`.
|
||||
- `totalsByCurrency` последнего Portfolio с ненулевым значением в другой
|
||||
валюте тоже даёт `NON_RUB_ACTIVITY`.
|
||||
- Учитываются только операции с `occurredAt ≤ fetchedAt` Portfolio этого
|
||||
счёта. Иначе пополнение после оценки исказит результат.
|
||||
- Дата потока — календарный день `occurredAt` в Europe/Moscow.
|
||||
|
||||
### Переводы между счетами в `all`
|
||||
|
||||
T-Bank не передаёт в операции счёт-контрагента. Совпадение операций —
|
||||
лишь кандидат на перевод, а не подтверждение. Не объявлять кандидата
|
||||
внутренним потоком без независимого признака связи.
|
||||
|
||||
- **Деньги.** Вывод на счёте A и пополнение на счёте B равной суммы RUB
|
||||
в пределах 5 минут — только кандидат. Пока нет подтверждения связи,
|
||||
оба счёта исключаются из расчётной части `all` с причиной
|
||||
`UNCONFIRMED_TRANSFER`. Без кандидата операция остаётся внешним потоком.
|
||||
- **Бумаги.** `OUTPUT_SECURITIES` / `TRANS_*` на счёте A и
|
||||
`INPUT_SECURITIES` / `TRANS_*` на счёте B с тем же инструментом и
|
||||
количеством в тот же день — только кандидат; оба счёта исключаются.
|
||||
Для одиночного ввода или вывода применяется `UNVALUED_SECURITIES_TRANSFER`.
|
||||
|
||||
В текущем контракте T-Bank нет признака, подтверждающего пару. Полностью
|
||||
корректное включение счетов с переводами в `all` отложено до появления
|
||||
подтверждённой связи; не вычислять процент на предположении.
|
||||
|
||||
### Причины недоступности
|
||||
|
||||
Причины проверяются по каждому счёту в таком порядке; используется первая
|
||||
сработавшая:
|
||||
|
||||
1. `HISTORY_START_UNKNOWN`: `historyBoundary = unknown`.
|
||||
2. `OPERATIONS_HISTORY_INCOMPLETE`: покрытие не `complete`.
|
||||
3. `PORTFOLIO_VALUATION_INCOMPLETE`: нет Portfolio или у ненулевой позиции
|
||||
нет вычислимой стоимости (то же правило, что у `portfolio-value-history`).
|
||||
4. `NON_RUB_ACTIVITY`.
|
||||
5. `UNVALUED_SECURITIES_TRANSFER`.
|
||||
6. `UNCONFIRMED_TRANSFER` (только `all`, когда есть кандидат на внутренний
|
||||
перевод без доказанной связи).
|
||||
|
||||
Как причины попадают в ответ:
|
||||
|
||||
- Для отдельного счёта любая причина даёт `data: null` и одну запись в
|
||||
`meta.unavailable`.
|
||||
- В `all` исключённые счета перечисляются в `excludedAccounts`, а
|
||||
`completeness = incomplete`. Если не осталось ни одного счёта, ответ
|
||||
`data: null`, а `meta.unavailable` содержит все счета.
|
||||
- `NO_SOLUTION` и `AMBIGUOUS_RETURN` бывают только в `return`
|
||||
(пользователя или индекса) и не
|
||||
исключает счёт.
|
||||
- Если даты последних полных оценок оставшихся счетов `all` в Москве
|
||||
различаются, сводка целиком `data: null` с причиной
|
||||
`ACCOUNT_VALUATION_DATES_DIFFER` в `meta.unavailable` для каждого
|
||||
затронутого счёта: суммы разных дат не складываются.
|
||||
- `INDEX_HISTORY_UNAVAILABLE` бывает только в `benchmark` / `reference`.
|
||||
|
||||
### Расчёт
|
||||
|
||||
- **Период.** `from` — дата первого внешнего потока по Москве (решение
|
||||
Change Owner: не дата открытия счёта). Полнота истории по-прежнему
|
||||
проверяется от `historyFrom`. `to` — дата по Москве `fetchedAt`; для `all` она общая
|
||||
только когда все включённые счета оценены в один московский день.
|
||||
`days = to − from` в календарных днях.
|
||||
`basis = annual` при `days ≥ 365`, иначе `period`.
|
||||
- **Агрегаты.** `netInvested = Σ потоков`,
|
||||
`currentValue = Σ totalsByCurrency[RUB]`,
|
||||
`profit = currentValue − netInvested`. Точная десятичная арифметика,
|
||||
финальное округление до копеек.
|
||||
- **XIRR.** Найти r из `Σ CFᵢ / (1 + r)^((dᵢ − d₀) / 365) = 0`, где
|
||||
пополнение входит как `−`, вывод — как `+`, а `currentValue` — как `+` в
|
||||
дату `to`. d₀ — дата первого потока. Корень ищется на `(−0.9999, 100]`.
|
||||
- Нет корня или корень не найден с точностью |NPV| ≤ 0,01 ₽ →
|
||||
`NO_SOLUTION`. Если решений более одного — `AMBIGUOUS_RETURN`,
|
||||
произвольный корень не выбирается. Смена знака на концах диапазона
|
||||
не доказывает единственность решения.
|
||||
- Нет потоков → `NO_SOLUTION`.
|
||||
- Для `basis = period` значение равно `(1 + r)^(days / 365) − 1`.
|
||||
- **Теневой портфель.** Для каждого потока берётся закрытие индекса на дату
|
||||
потока или на последнюю торговую дату до неё:
|
||||
`units += CF / close` (масштаб 12 знаков).
|
||||
`benchmark.value = units × close(asOf)`, где `asOf` — последнее закрытие
|
||||
`≤ to`. XIRR индекса считается на тех же потоках с `benchmark.value` в
|
||||
дату `to`.
|
||||
- **Опережение.**
|
||||
`outperformance.percentagePoints = return.value − benchmark.return.value`
|
||||
(одинаковый `basis`), `outperformance.money = currentValue − benchmark.value`.
|
||||
Если любая из сторон `null`, `outperformance = null`.
|
||||
- **Series.** Одна точка на торговый день MCFTRR в `[from, to]`:
|
||||
- `netInvested` — накопленная сумма потоков к этому дню;
|
||||
- `benchmarkValue` — `units × close` на этот день;
|
||||
- `referenceValue` — то же для IMOEX, `null` при его недоступности.
|
||||
|
||||
Около 250 точек в год, без прореживания.
|
||||
|
||||
### Индексы MOEX
|
||||
|
||||
- Хранится (индекс, торговый день по Москве) → точное CLOSE, upsert.
|
||||
Нерабочие дни не хранятся.
|
||||
- **Свежесть.** Расширение диапазона назад загружается сразу; хвост до
|
||||
`to`, где закрытия ещё нет, перепроверяется не чаще раза в час после
|
||||
успешной проверки; повтор после неудачной попытки — не чаще раза в час.
|
||||
На каждый индекс
|
||||
одновременно идёт не больше одной догрузки, повторные чтения ждут её
|
||||
результат. Первая загрузка начинается с `from − 10 дней`, чтобы было
|
||||
закрытие для потока в выходной.
|
||||
- **Достаточное покрытие.** Есть закрытие `≤` даты первого потока,
|
||||
подтверждённый диапазон доходит до даты последнего потока и
|
||||
последнее закрытие `≥ to − 10` календарных дней. 10 дней покрывают
|
||||
новогодние каникулы биржи. Иначе `INDEX_HISTORY_UNAVAILABLE`.
|
||||
- **Лицензия.** ISS бесплатно отдаёт данные для ознакомления. Приложение
|
||||
личное, локальное и не распространяет данные. Источник указывается в
|
||||
«Как считали».
|
||||
|
||||
## Error and Recovery Semantics
|
||||
|
||||
| Ситуация | Тип | Ответ | Восстановление |
|
||||
| --- | --- | --- | --- |
|
||||
| Нет сессии / нет подключения / счёт не найден или недоступен | Доступ | 401 / 503 `TBANK_NOT_CONNECTED` / 404 | Существующие сценарии счёта |
|
||||
| Причина недоступности счёта | Бизнес | 200, `data: null` или `excludedAccounts` | Исчезает после синхронизации или изменения данных |
|
||||
| XIRR не сходится или неоднозначен | Бизнес | 200, `return.unavailableReason = NO_SOLUTION` либо `AMBIGUOUS_RETURN` | Нет |
|
||||
| MOEX недоступен, сохранённой истории хватает | Технический | 200, полный результат | Догрузка при следующем чтении (не чаще раза в час) |
|
||||
| MOEX недоступен, истории не хватает | Технический | 200, `benchmark.unavailableReason = INDEX_HISTORY_UNAVAILABLE`, `series: []` | То же |
|
||||
| Сбой хранилища или непредвиденная ошибка | Технический | 500 Problem Details | Повтор из UI; другие вкладки не затронуты |
|
||||
|
||||
Таймаут MOEX — 10 с на запрос, как у существующего клиента. Повторных
|
||||
попыток внутри одного чтения нет. Сбой догрузки не удаляет сохранённые
|
||||
закрытия.
|
||||
|
||||
## Non-Functional and Operational Constraints
|
||||
|
||||
- **Ответ.** Без догрузки индекса — меньше 1 с для 10 лет истории (~2 500
|
||||
точек на индекс, до нескольких тысяч операций). С холодной догрузкой —
|
||||
до `ceil(дней_торгов / 100)` запросов к ISS (~25 на 10 лет на индекс).
|
||||
- **Наблюдаемость.** События `integration.moex.index_history.succeeded` и
|
||||
`.failed` с индексом, диапазоном, числом страниц и длительностью. Счёт и
|
||||
суммы в логи не пишутся.
|
||||
- **Безопасность.** Значения отдаются только после подтверждения доступа,
|
||||
как у остальных инвестиционных ресурсов. В MOEX не уходят данные
|
||||
пользователя.
|
||||
|
||||
## Compatibility and Migration
|
||||
|
||||
- Новые маршруты и новое хранилище; существующие контракты не меняются.
|
||||
- Миграция данных не нужна: история индекса заполняется при первом чтении.
|
||||
- Откат: удалить маршруты и хранилище. Пользовательские данные не
|
||||
затрагиваются.
|
||||
|
||||
## Open Questions / Assumptions
|
||||
|
||||
- **Assumption.** Окно 5 минут и правила парности переводов соответствуют
|
||||
реальным операциям T-Bank. Проверяется на данных пользователя до закрытия
|
||||
change; при расхождении правило уточняется здесь.
|
||||
- **Assumption.** Остаток в валюте до 1 единицы (например, копейки USD
|
||||
после конвертации) всё равно даёт `NON_RUB_ACTIVITY`. Порог не вводится,
|
||||
пока такой случай не встретится на реальных данных.
|
||||
- **Elaboration.** Для `market-index-history` уточнены параметры: окно
|
||||
покрытия 10 дней и интервал повторных попыток 1 час. Новое поведение они
|
||||
не вводят.
|
||||
- **Spec alignment.** Формулировка причины уточнена: «ввод или вывод ценных
|
||||
бумаг без денежной оценки». В delta spec и прототипе текст обновлён: вывод
|
||||
бумаг без оценки так же искажает расчёт.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WealthLedger investment performance (target contract, WealthLedger/main#54)
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Целевой контракт новых маршрутов. После реализации исполняемым источником
|
||||
становится backend-generated OpenAPI, который MUST быть семантически
|
||||
совместим с этим файлом. Общие схемы `MoneyDto`, `ProblemDetailsDto` и
|
||||
`InvestmentDependencyDto` остаются в опубликованном OpenAPI и здесь только
|
||||
упоминаются.
|
||||
paths:
|
||||
/api/v1/brokerage-accounts/{accountId}/performance:
|
||||
get:
|
||||
operationId: getBrokerageAccountPerformance
|
||||
summary: Результат инвестирования счёта за всё время
|
||||
parameters:
|
||||
- name: accountId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, minLength: 1 }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Performance' }
|
||||
'401': { $ref: '#/components/responses/Problem' }
|
||||
'404': { $ref: '#/components/responses/Problem' }
|
||||
'503': { $ref: '#/components/responses/Problem' }
|
||||
/api/v1/brokerage-accounts/all/performance:
|
||||
get:
|
||||
operationId: getAllBrokerageAccountsPerformance
|
||||
summary: Результат инвестирования режима «Все счета»
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Performance' }
|
||||
'401': { $ref: '#/components/responses/Problem' }
|
||||
'503': { $ref: '#/components/responses/Problem' }
|
||||
components:
|
||||
responses:
|
||||
Problem:
|
||||
description: Ошибка доступа, подключения или технический сбой (существующий Problem Details; коды из PROBLEM_CODES)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema: { $ref: 'openapi.json#/components/schemas/ProblemDetailsDto' }
|
||||
Performance:
|
||||
description: Результат либо явная недоступность. Недоступность расчёта — не ошибка HTTP.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PerformanceResource' }
|
||||
schemas:
|
||||
Decimal:
|
||||
type: string
|
||||
pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$'
|
||||
description: Точное десятичное значение без экспоненты и незначащих нулей
|
||||
Rate:
|
||||
allOf: [{ $ref: '#/components/schemas/Decimal' }]
|
||||
description: Доля, не проценты. 0.151 = 15,1 %. Округление до 6 знаков, half away from zero.
|
||||
Money:
|
||||
$ref: 'openapi.json#/components/schemas/MoneyDto'
|
||||
UnavailableReason:
|
||||
type: string
|
||||
enum:
|
||||
- HISTORY_START_UNKNOWN
|
||||
- OPERATIONS_HISTORY_INCOMPLETE
|
||||
- NON_RUB_ACTIVITY
|
||||
- UNVALUED_SECURITIES_TRANSFER
|
||||
- UNCONFIRMED_TRANSFER
|
||||
- ACCOUNT_VALUATION_DATES_DIFFER
|
||||
- PORTFOLIO_VALUATION_INCOMPLETE
|
||||
- NO_SOLUTION
|
||||
- AMBIGUOUS_RETURN
|
||||
- INDEX_HISTORY_UNAVAILABLE
|
||||
description: |
|
||||
Новые значения MAY добавляться; клиент MUST показывать неизвестное
|
||||
значение как общую недоступность, а не как ошибку.
|
||||
ReturnValue:
|
||||
type: object
|
||||
required: [value, basis, unavailableReason]
|
||||
properties:
|
||||
value:
|
||||
oneOf: [{ $ref: '#/components/schemas/Rate' }, { type: 'null' }]
|
||||
basis:
|
||||
type: string
|
||||
enum: [annual, period]
|
||||
description: annual при period.days >= 365, иначе period (доля за весь период)
|
||||
unavailableReason:
|
||||
oneOf: [{ $ref: '#/components/schemas/UnavailableReason' }, { type: 'null' }]
|
||||
IndexResult:
|
||||
type: object
|
||||
required: [code, value, return, asOf, unavailableReason]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [MCFTRR, IMOEX]
|
||||
value:
|
||||
oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
|
||||
description: Стоимость тех же потоков в индексе на конец периода (RUB)
|
||||
return: { $ref: '#/components/schemas/ReturnValue' }
|
||||
asOf:
|
||||
oneOf: [{ type: string, format: date }, { type: 'null' }]
|
||||
description: Дата последнего использованного закрытия индекса (Europe/Moscow)
|
||||
unavailableReason:
|
||||
oneOf: [{ $ref: '#/components/schemas/UnavailableReason' }, { type: 'null' }]
|
||||
Outperformance:
|
||||
type: object
|
||||
required: [percentagePoints, money]
|
||||
properties:
|
||||
percentagePoints:
|
||||
$ref: '#/components/schemas/Rate'
|
||||
description: Разница долей return пользователя и benchmark (того же basis)
|
||||
money: { $ref: '#/components/schemas/Money' }
|
||||
SeriesPoint:
|
||||
type: object
|
||||
required: [date, netInvested, benchmarkValue, referenceValue]
|
||||
properties:
|
||||
date: { type: string, format: date }
|
||||
netInvested: { $ref: '#/components/schemas/Decimal' }
|
||||
benchmarkValue: { $ref: '#/components/schemas/Decimal' }
|
||||
referenceValue:
|
||||
oneOf: [{ $ref: '#/components/schemas/Decimal' }, { type: 'null' }]
|
||||
description: Значения RUB на торговый день MCFTRR; валюта точки всегда RUB
|
||||
ExcludedAccount:
|
||||
type: object
|
||||
required: [accountId, name, reason]
|
||||
properties:
|
||||
accountId: { type: string }
|
||||
name: { type: string }
|
||||
reason: { $ref: '#/components/schemas/UnavailableReason' }
|
||||
Performance:
|
||||
type: object
|
||||
required:
|
||||
- accountId
|
||||
- period
|
||||
- netInvested
|
||||
- currentValue
|
||||
- profit
|
||||
- return
|
||||
- benchmark
|
||||
- reference
|
||||
- outperformance
|
||||
- series
|
||||
- completeness
|
||||
- excludedAccounts
|
||||
- calculatedAt
|
||||
properties:
|
||||
accountId: { type: string, description: 'id счёта или all' }
|
||||
period:
|
||||
type: object
|
||||
required: [from, to, days]
|
||||
properties:
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date, description: 'дата последней полной оценки Portfolio' }
|
||||
days: { type: integer, minimum: 0 }
|
||||
netInvested: { $ref: '#/components/schemas/Money' }
|
||||
currentValue: { $ref: '#/components/schemas/Money' }
|
||||
profit: { $ref: '#/components/schemas/Money' }
|
||||
return: { $ref: '#/components/schemas/ReturnValue' }
|
||||
benchmark:
|
||||
allOf: [{ $ref: '#/components/schemas/IndexResult' }]
|
||||
description: code = MCFTRR
|
||||
reference:
|
||||
allOf: [{ $ref: '#/components/schemas/IndexResult' }]
|
||||
description: code = IMOEX; только справка
|
||||
outperformance:
|
||||
oneOf: [{ $ref: '#/components/schemas/Outperformance' }, { type: 'null' }]
|
||||
description: null, если недоступен return пользователя или benchmark
|
||||
series:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SeriesPoint' }
|
||||
description: Пусто, если benchmark недоступен
|
||||
completeness:
|
||||
type: string
|
||||
enum: [complete, incomplete]
|
||||
excludedAccounts:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ExcludedAccount' }
|
||||
description: Только для all; incomplete <=> непустой массив
|
||||
calculatedAt: { type: string, format: date-time }
|
||||
PerformanceResource:
|
||||
type: object
|
||||
required: [data, meta]
|
||||
properties:
|
||||
data:
|
||||
oneOf: [{ $ref: '#/components/schemas/Performance' }, { type: 'null' }]
|
||||
description: null, если результат недоступен целиком
|
||||
meta:
|
||||
type: object
|
||||
required: [dependencies, unavailable]
|
||||
properties:
|
||||
dependencies:
|
||||
type: array
|
||||
items: { $ref: 'openapi.json#/components/schemas/InvestmentDependencyDto' }
|
||||
unavailable:
|
||||
type: array
|
||||
description: Непусто <=> data = null; причины по каждому счёту
|
||||
items: { $ref: '#/components/schemas/ExcludedAccount' }
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
sequenceDiagram
|
||||
participant API as WealthLedger backend
|
||||
participant Idx as Saved index history
|
||||
participant MOEX as MOEX ISS
|
||||
API->>Idx: Последняя сохранённая дата, время последней попытки (по индексу)
|
||||
alt Покрыто до дня `to` или попытка была < 1 ч назад
|
||||
Idx-->>API: Сохранённые закрытия
|
||||
else Нужна догрузка (одна одновременная попытка на индекс)
|
||||
loop Страницы по 100 строк (start = 0, 100, …) до TOTAL
|
||||
API->>MOEX: GET /iss/history/engines/stock/markets/index/securities/{SECID}.json<br/>?from&till&start&iss.meta=off&history.columns=TRADEDATE,CLOSE
|
||||
alt 200 с валидными строками
|
||||
MOEX-->>API: TRADEDATE, CLOSE + history.cursor(INDEX,TOTAL,PAGESIZE)
|
||||
API->>Idx: Upsert (индекс, торговый день) → точное закрытие
|
||||
else Таймаут 10 с / не-2xx / невалидный ответ
|
||||
MOEX-->>API: сбой
|
||||
Note over API: Прервать догрузку; сохранённое не удалять;<br/>лог integration.moex.index_history.failed
|
||||
end
|
||||
end
|
||||
Idx-->>API: Сохранённые закрытия
|
||||
end
|
||||
alt Последнее закрытие ≥ to − 10 календарных дней
|
||||
Note over API: Покрытие достаточно; asOf = последняя дата закрытия
|
||||
else Иначе
|
||||
Note over API: INDEX_HISTORY_UNAVAILABLE для этого индекса
|
||||
end
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant UI as WealthLedger frontend
|
||||
participant API as WealthLedger backend
|
||||
participant Store as Saved investment data
|
||||
participant Idx as Saved index history
|
||||
participant MOEX as MOEX ISS
|
||||
User->>UI: Открывает вкладку «Результат» (accountId | all)
|
||||
UI->>API: GET /brokerage-accounts/{accountId|all}/performance
|
||||
API->>Store: Сессия, connection, доступ к счёту(ам)
|
||||
alt Нет сессии / нет подключения / счёт недоступен
|
||||
API-->>UI: Problem Details 401 / 503 TBANK_NOT_CONNECTED / 404
|
||||
UI-->>User: Существующие состояния ошибки счёта
|
||||
else Доступ подтверждён
|
||||
API->>Store: Граница истории, покрытие операций, последняя полная оценка Portfolio
|
||||
Note over API: По каждому счёту: причины недоступности<br/>(HISTORY_START_UNKNOWN, OPERATIONS_HISTORY_INCOMPLETE,<br/>NON_RUB_ACTIVITY, UNVALUED_SECURITIES_TRANSFER,<br/>PORTFOLIO_VALUATION_INCOMPLETE, UNCONFIRMED_TRANSFER)<br/>all: ACCOUNT_VALUATION_DATES_DIFFER<br/>XIRR: NO_SOLUTION / AMBIGUOUS_RETURN
|
||||
alt Не осталось счетов для расчёта
|
||||
API-->>UI: 200 {data: null, meta.unavailable: [...]}
|
||||
UI-->>User: «Доходность недоступна» + причина
|
||||
else Есть счета для расчёта
|
||||
API->>API: Внешние потоки по дате, чистые вложения, прибыль, XIRR
|
||||
API->>Idx: Закрытия MCFTRR, IMOEX за [from, to]
|
||||
opt История не покрывает период (см. index-refresh.mmd)
|
||||
Idx->>MOEX: Догрузка недостающих торговых дней
|
||||
end
|
||||
alt MCFTRR покрывает период
|
||||
API->>API: Теневой портфель, XIRR индекса, опережение, series
|
||||
else MCFTRR недоступен
|
||||
API->>API: benchmark/outperformance недоступны (INDEX_HISTORY_UNAVAILABLE), series = []
|
||||
end
|
||||
API-->>UI: 200 {data: Performance, meta.dependencies}
|
||||
UI-->>User: Показатели, график, «Как считали», предупреждение о неполноте
|
||||
end
|
||||
end
|
||||
alt Технический сбой backend
|
||||
API-->>UI: Problem Details 5xx
|
||||
UI-->>User: Ошибка только во вкладке «Результат», с повтором
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# Tasks
|
||||
|
||||
Issue: WealthLedger/main#54. Ветки в каждом repo:
|
||||
`feature/54-investment-performance-vs-index` от актуального `develop`.
|
||||
|
||||
## 1. Backend: история индексов
|
||||
|
||||
- [x] 1.1 [backend] Хранилище дневных закрытий индексов и состояния догрузки (аддитивная миграция)
|
||||
- **Covers:** `instruments/market-index-history` — `Дневная история индексов Мосбиржи`
|
||||
- **Source:** design «Data / Storage Changes»
|
||||
- **Verification:** `npx prisma migrate dev` на одноразовой копии БД; `npm run verify`
|
||||
- **Depends on:** none
|
||||
- [x] 1.2 [backend] `MoexClient.indexHistory(secid, from, till)`: пагинация `history.cursor`, строгий разбор, сбой без частичных строк
|
||||
- **Covers:** `instruments/market-index-history` — `Дневная история индексов Мосбиржи`
|
||||
- **Source:** system-analysis «Contracts» (MOEX ISS), [index-refresh.mmd](system-analysis/sequences/index-refresh.mmd); design backend 5
|
||||
- **Verification:** `moex.client.spec.ts`: несколько страниц, невалидная страница, таймаут, не-2xx
|
||||
- **Depends on:** none
|
||||
- [x] 1.3 [backend] `MarketIndexHistoryService`: покрытие периода, догрузка не чаще раза в час, single-flight, upsert, лог `integration.moex.index_history.*`, сбой → недостаточно покрытия
|
||||
- **Covers:** `instruments/market-index-history` — `Дневная история индексов Мосбиржи`, `Свежесть и недоступность истории индексов`
|
||||
- **Source:** system-analysis «Индексы MOEX», «Error and Recovery Semantics»; design backend 5, «Cross-cutting Implementation»
|
||||
- **Verification:** unit-тесты сервиса (покрытие ±10 дн., час, параллельные вызовы, сбой с историей и без); `npm run verify`
|
||||
- **Depends on:** 1.1, 1.2
|
||||
|
||||
## 2. Backend: расчёт результата
|
||||
|
||||
- [x] 2.1 [backend] Вынести `isCompleteValuation(snapshot)` в portfolio domain и использовать его при записи истории оценки
|
||||
- **Covers:** `portfolio/investment-performance` — `Недоступный и неполный результат`
|
||||
- **Source:** design backend 2; qa-analysis «Regression Impact»
|
||||
- **Verification:** существующие тесты `portfolio-value-history` и новые unit-тесты функции проходят; `npm run verify`
|
||||
- **Depends on:** none
|
||||
- [x] 2.2 [backend] Domain: маппинг типов операций → роли, внешние потоки по дате Europe/Moscow, отсечка по `fetchedAt`, кандидаты на переводы в `all` без автоматического подтверждения, причины недоступности в порядке приоритета
|
||||
- **Covers:** `portfolio/investment-performance` — `Внешние денежные потоки`, `Недоступный и неполный результат`
|
||||
- **Source:** system-analysis «Маппинг операций T-Bank», «Переводы между счетами в all», «Причины недоступности»; design backend 4, 7
|
||||
- **Verification:** unit-тесты: счёт A (130 000 / 10 000), `UNCONFIRMED_TRANSFER` при совпадении операций, перевод на исключённый счёт, бумаги без пары, USD, порядок причин
|
||||
- **Depends on:** none
|
||||
- [x] 2.3 [backend] Domain: XIRR (Ньютон + бисекция, проверка знака), basis annual/period, теневой портфель MCFTRR/IMOEX, опережение, series
|
||||
- **Covers:** `portfolio/investment-performance` — `Доходность с учётом сумм и дат потоков`, `Те же деньги в индексе Мосбиржи`, `Справочный индекс без дивидендов`, `График результата`
|
||||
- **Source:** system-analysis «Расчёт»; design backend 3
|
||||
- **Verification:** unit-тесты с эталонными константами (LibreOffice/scipy, допуск 1e-6): 10 % за 730 дн., 110 000 ₽ по индексу, поток в выходной, `NO_SOLUTION`, `AMBIGUOUS_RETURN`, period basis, отставание
|
||||
- **Depends on:** none
|
||||
- [x] 2.4 [backend] Модуль `performance`: маршруты `GET /brokerage-accounts/{accountId|all}/performance`, доступ, сборка ответа, DTO, `UnavailableReason`, лог `performance.calculated`
|
||||
- **Covers:** `portfolio/investment-performance` — `Результат инвестирования выбранного счёта`, `Недоступный и неполный результат`; `instruments/market-index-history` — `Свежесть и недоступность истории индексов`
|
||||
- **Source:** contract [investment-performance.openapi.yaml](system-analysis/contracts/investment-performance.openapi.yaml), [performance-read.mmd](system-analysis/sequences/performance-read.mmd); design backend 1, 6
|
||||
- **Verification:** e2e на SQLite (`npm run test:sqlite`): 200 ready, `data: null`, `all` incomplete, 401/404/503, MOEX недоступен с историей и без; `npm run verify`
|
||||
- **Depends on:** 1.3, 2.1, 2.2, 2.3
|
||||
- [x] 2.5 [backend] Обновить generated OpenAPI и сверить его с целевым контрактом
|
||||
- **Covers:** `portfolio/investment-performance` — `Результат инвестирования выбранного счёта`
|
||||
- **Source:** design «Contract Implementation»
|
||||
- **Verification:** `npm run openapi:generate`; `npm run openapi:check`; построчная сверка маршрутов, required, nullable, enum и форматов с `investment-performance.openapi.yaml`, результат — в описании PR
|
||||
- **Depends on:** 2.4
|
||||
|
||||
## 3. Frontend
|
||||
|
||||
- [x] 3.1 [frontend] Обновить OpenAPI snapshot и типы, entity `performance` с `usePerformance(accountId)`
|
||||
- **Covers:** `portfolio/investment-performance` — `Результат инвестирования выбранного счёта`
|
||||
- **Source:** design frontend 1, «Contract Implementation»
|
||||
- **Verification:** `npm run openapi:generate`; `npm run openapi:check`; `npm run typecheck`
|
||||
- **Depends on:** 2.5
|
||||
- [x] 3.2 [frontend] Форматтеры `formatSignedPercent`, `formatPercentagePoints` (знак U+2212, 1 знак)
|
||||
- **Covers:** `portfolio/investment-performance` — `Вкладка «Результат» на странице счёта`
|
||||
- **Source:** design frontend 4
|
||||
- **Verification:** unit-тесты: положительное, отрицательное, ноль, округление
|
||||
- **Depends on:** none
|
||||
- [x] 3.3 [frontend] Вкладка «Результат»: container, VM, view, KPI, alerts, недоступно, skeleton, ошибка секции, «Как считали», hint, строки `portfolio.performance.*`
|
||||
- **Covers:** `portfolio/investment-performance` — `Вкладка «Результат» на странице счёта`, `Недоступный и неполный результат`
|
||||
- **Source:** design frontend 2, 3, 6, 7, «UI/UX Package»; prototype вариант A
|
||||
- **Verification:** тесты VM и view (RTL + MSW): обгоняю, отстаю, меньше года, неполный, недоступно, неизвестная причина, загрузка, ошибка только во вкладке; `npm run verify`
|
||||
- **Depends on:** 3.1, 3.2
|
||||
- [x] 3.4 [frontend] График результата: LineChart с тремя сериями и точкой текущей стоимости, tooltip, текстовая альтернатива, пояснение об отсутствии прошлой стоимости, состояние без series
|
||||
- **Covers:** `portfolio/investment-performance` — `График результата`, `Справочный индекс без дивидендов`
|
||||
- **Source:** design frontend 5, «UI/UX Package»
|
||||
- **Verification:** тесты view: серии, `aria-label` с итогами, отсутствие референсной линии, пустой series; browser-тест на 390px без горизонтальной прокрутки; `npm run verify`
|
||||
- **Depends on:** 3.3
|
||||
|
||||
## 4. Docs
|
||||
|
||||
- [x] 4.1 [docs] ADR 023 и строка в `adr/README.md`
|
||||
- **Covers:** `N/A` — документация решения
|
||||
- **Source:** docs-impact: `docs/adr/023-investment-performance-vs-index.md`, `docs/adr/README.md`
|
||||
- **Verification:** `npm run verify`
|
||||
- **Depends on:** none
|
||||
- [x] 4.2 [docs] Страница `domains/portfolio/investment-performance.md` и обновления `all-accounts.md`, `data-reference.md`, `portfolio/overview.md`
|
||||
- **Covers:** `N/A` — документация текущего поведения
|
||||
- **Source:** docs-impact: соответствующие строки таблицы
|
||||
- **Verification:** `npm run verify`; сверка с реализованным поведением 2.4 и 3.3
|
||||
- **Depends on:** 2.4, 3.3, 4.1
|
||||
- [x] 4.3 [docs] Глоссарий, `overview.md`, `income/overview.md`: новые термины и снятие конфликтующих запретов
|
||||
- **Covers:** `N/A` — терминология
|
||||
- **Source:** docs-impact: `docs/glossary.md`, `docs/overview.md`, `docs/domains/income/overview.md`
|
||||
- **Verification:** `npm run verify`; `grep -rn "доходност" .` — только новое значение или оговорки «не является»
|
||||
- **Depends on:** 4.1
|
||||
- [x] 4.4 [docs] `integrations/moex.md` (раздел «Индексы»), `architecture/context.md`, `architecture/http-api.md`
|
||||
- **Covers:** `N/A` — документация интеграции и API
|
||||
- **Source:** docs-impact: соответствующие строки таблицы
|
||||
- **Verification:** `npm run verify`; сверка с generated OpenAPI из 2.5
|
||||
- **Depends on:** 2.5
|
||||
|
||||
## 5. Завершение
|
||||
|
||||
- [x] 5.1 [specs] Commit planning artifacts и прототипа; PR в `develop`
|
||||
- **Covers:** `N/A` — поставка planning artifacts
|
||||
- **Source:** engineering workflow
|
||||
- **Verification:** `npm run validate`; `git diff --check`
|
||||
- **Depends on:** none
|
||||
|
||||
Ручная приёмка на реальных данных Change Owner (парность переводов,
|
||||
правдоподобие результата) и semantic verify/archive выполняются по
|
||||
qa-analysis до закрытия Issue. Это не implementation task.
|
||||
Reference in New Issue
Block a user