spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54)
Conventional commits / commits (pull_request) Successful in 15s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 12s

This commit is contained in:
2026-11-07 20:09:57 +03:00
parent 1b20067ca7
commit 5625c52b29
13 changed files with 1598 additions and 0 deletions
@@ -0,0 +1,136 @@
# 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 с парой вывод/пополнение: `all.netInvested` не меняется от перевода |
| Внешние потоки: перевод на счёт вне результата | Счёт 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` |
| Индекс: пополнение и рост индекса | Подменённая история (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 предупреждение |
| Недоступно: индекс недоступен | 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
- **Парность переводов** не подтверждена на реальных данных. Routing: System
Analysis (уточнить правило) по итогам ручной проверки до закрытия change.
- **Sandbox T-Bank** может не воспроизвести все наборы. Routing: Test Data —
наполнение одноразовой SQLite-копии; не блокирует.