19 Commits
Author SHA1 Message Date
codex-bot 892800301b spec: plan investment performance vs Moscow Exchange index (WealthLedger/main#54) (#85)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
2026-11-08 16:30:32 +03:00
ksv741 1b20067ca7 chore(release): 0.2.1
Conventional commits / commits (pull_request) Successful in 9s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-11-06 07:41:32 +03:00
codex-bot 142f7aa225 spec: align Events and CURRENCY portfolio behavior (WealthLedger/specs#60)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 11s
2026-11-05 07:16:08 +03:00
codex-bot 8fc8e0a750 chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#81)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-11-03 14:07:42 +03:00
codex-bot 0469756d3b spec: allow warning and retry after exhausted read retries (WealthLedger/specs#79) (#80)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-11-03 07:45:09 +03:00
codex-bot 8b2b742e52 chore: drop OpenSpec CLI version pin and strict validation (WealthLedger/specs#67) (#78)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Canceled after 0s
Specs security / security (pull_request) Canceled after 0s
Specs verify / verify (pull_request) Canceled after 0s
2026-11-03 06:52:48 +03:00
codex-bot aa1a57ac20 spec: treat MOEX response without requested block as source failure (WealthLedger/specs#76) (#77)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 12s
2026-11-03 06:12:27 +03:00
codex-bot 3821d9ed58 refactor: group canonical specs by domain and archive account-top-positions (#74)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 14s
2026-11-02 21:50:49 +03:00
codex-bot 1fd7e6ad97 ci: run native OpenSpec and local link validation in verify (WealthLedger/specs#2)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 15s
npm run validate: toolchain check, openspec schema validate, openspec validate --all --strict, lychee --offline --include-fragments for root *.md and openspec/specs. CI verify installs the pinned CLI and sha256-pinned lychee and runs it.

Closes #2
2026-11-02 21:06:17 +03:00
codex-bot 7f73de76ac ci: check whitespace in committed PR/push range (WealthLedger/specs#2)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 3s
verify ran git diff --check on a clean checkout (always no-op). Now checks PR merge-base..head or push before..after; zero before falls back to the last commit. Node setup removed from the git-only job.
2026-11-02 20:36:58 +03:00
ksv741 97f358545f chore(release): 0.2.0
Conventional commits / commits (pull_request) Successful in 9s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 8s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 8s
2026-11-01 19:34:04 +03:00
codex-bot c66a5cd962 spec: record account-top-positions planning and implementation (WealthLedger/main#48) (#69)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-11-01 19:29:11 +03:00
codex-bot b9ec11f5ba refactor: make sdd-lab review-driven (WealthLedger/specs#66)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
Replace executable schema checks with explicit review-driven readiness, add a requirement-writing profile, and incorporate review clarifications for source traceability and comparative validation.
2026-10-30 13:35:39 +03:00
codex-bot a2d2df3b5a spec: archive group-account-rows-by-instrument and sync canonical specs (WealthLedger/main#47) (#65)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 7s
2026-10-30 07:35:30 +03:00
codex-bot f3a6b78461 spec: plan grouping of all-account rows by instrument (WealthLedger/main#47) (#64)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-10-30 07:28:31 +03:00
codex-bot 322ddf6a7f feat: add experimental wealth-ledger-sdd-lab OpenSpec schema (#61)
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 7s
2026-10-29 05:58:29 +03:00
ksv741 093185d44d chore(release): 0.1.6
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 10s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
2026-10-24 19:15:05 +03:00
renovate-bot 08646ff505 chore(deps): update non-major dependencies (WealthLedger/specs#56)
Specs security / security (push) Successful in 16s
Specs verify / verify (push) Successful in 11s
Co-authored-by: renovate-bot <renovate-bot@ksv741.ru>
2026-10-24 19:04:54 +03:00
codex-bot c5b9cadbbd chore: bump Node to 24.22.1 (WealthLedger/backend#122)
Specs security / security (push) Successful in 10s
Specs verify / verify (push) Successful in 13s
2026-10-23 16:20:01 +03:00
115 changed files with 9170 additions and 2152 deletions
+33 -2
View File
@@ -15,8 +15,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Check whitespace in committed range
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
if [ -n "$PR_HEAD" ]; then
range="$PR_BASE...$PR_HEAD"
elif [ -n "$PUSH_BEFORE" ] && ! expr "$PUSH_BEFORE" : '0*$' >/dev/null; then
range="$PUSH_BEFORE..$PUSH_AFTER"
else
# New branch / unknown before: only the pushed commit; full tree would hit read-only archives.
range="$PUSH_AFTER^..$PUSH_AFTER"
fi
echo "Checked revision: $(git rev-parse HEAD); range: $range"
git diff --check "$range"
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci
- run: npm run verify
- name: Install OpenSpec CLI and lychee 0.24.2
run: |
npm install -g @fission-ai/openspec
openspec store register .
curl --fail --location --silent --show-error https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-gnu.tar.gz -o /tmp/lychee.tar.gz
echo '1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a /tmp/lychee.tar.gz' | sha256sum --check
tar -xzf /tmp/lychee.tar.gz -C /tmp
echo /tmp/lychee-x86_64-unknown-linux-gnu >> "$GITHUB_PATH"
env:
OPENSPEC_TELEMETRY: '0'
- name: Validate schemas, specs, changes and local links
run: npm run validate
env:
OPENSPEC_TELEMETRY: '0'
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+3 -2
View File
@@ -14,8 +14,9 @@ HTTP-провайдер публикует контракт, а потребит
и локально проверяет generated types. Specs фиксируют behavioral requirements;
другие checkout не нужны для чтения и native validation этого store.
Архивы и чужие active changes сохраняй. Для правки индекса проверь targets,
anchors и `git diff --check`; application gates принадлежат affected repos.
Архивы и чужие active changes сохраняй. Для правки индекса или specs выполни
`npm run validate` (targets/anchors, native checks) и `git diff --check`;
application gates принадлежат affected repos.
Если явно запрошена работа через OpenSpec, следуй
[локальным инструкциям OpenSpec](README.md#начало-работы)
и используй store `wealth-ledger-specs`. Перед Node-командами активируй
+36 -26
View File
@@ -12,30 +12,40 @@ HTTP OpenAPI имеет единственный editable source в backend, а
Потребитель доверяет предоставленному artifact и локально проверяет generated
types. CI каждого участника использует только его собственные входы.
| Тема / trigger | Existing spec |
| --- | --- |
| Auth/session/refresh/error contract | [User authentication](openspec/specs/user-authentication/spec.md) |
| T-Bank connection/security/cache generation | [T-Bank connection](openspec/specs/tbank-connection/spec.md) |
| Владелец синхронизации T-Bank, scheduling/priority | [T-Bank data sync](openspec/specs/tbank-data-sync/spec.md) |
| Часовой обход, результат последней попытки | [Hourly investment sync](openspec/specs/hourly-investment-sync/spec.md) |
| Счета/сводки | [Brokerage accounts](openspec/specs/brokerage-accounts/spec.md) |
| Portfolio positions/totals/stale | [Portfolio](openspec/specs/portfolio-view/spec.md) |
| История стоимости Portfolio | [Portfolio value history](openspec/specs/portfolio-value-history/spec.md) |
| Справочные сведения/поиск инструментов (online-only) | [Online instrument information](openspec/specs/online-instrument-information/spec.md) |
| Ручные дебетовые карты | [Manual bank cards](openspec/specs/manual-bank-cards/spec.md) |
| Операции/cursor/filters | [Account ledger](openspec/specs/brokerage-account-ledger/spec.md) |
| Будущие выплаты | [Upcoming events](openspec/specs/brokerage-account-upcoming-events/spec.md) |
| Доходы и периоды | [Income analytics](openspec/specs/brokerage-account-income-analytics/spec.md) |
| Shared tokens/theme | [Design system](openspec/specs/frontend-design-system/spec.md) |
| Каталог компонентов | [UI catalog](openspec/specs/frontend-ui-catalog/spec.md) |
| Loading/skeleton | [Loading presentation](openspec/specs/frontend-loading-presentation/spec.md) |
| UI error/recovery | [UI resilience](openspec/specs/frontend-ui-resilience/spec.md) |
| Тихое чтение и редкие уведомления | [User feedback](openspec/specs/frontend-user-feedback/spec.md) |
| Query coherence/cache | [Query coherence](openspec/specs/frontend-query-coherence/spec.md), [data resilience](openspec/specs/frontend-data-resilience/spec.md) |
| OpenAPI generation | [API contract generation](openspec/specs/api-contract-generation/spec.md) |
| Logging/request context | [Structured logging](openspec/specs/backend-structured-logging/spec.md) |
| Breadcrumb/navigation | [Breadcrumbs](openspec/specs/app-breadcrumb-navigation/spec.md) |
Specs сгруппированы по доменам: `openspec/specs/<domain>/<capability>/spec.md`.
Новая capability кладётся в существующий домен; новый домен — только для
отдельного бизнес-контекста. Процессные правила сюда не входят.
Process specs, active changes и archives сохраняют историю прежнего SDD workflow.
Они не являются обязательной процедурой текущей задачи. Для явно запрошенной
работы с OpenSpec используй [отдельный вход](AGENTS.md).
| Домен | Тема / trigger | Existing spec |
| --- | --- | --- |
| identity | Auth/session/refresh/error contract | [User authentication](openspec/specs/identity/user-authentication/spec.md) |
| tbank | T-Bank connection/security/cache generation | [T-Bank connection](openspec/specs/tbank/tbank-connection/spec.md) |
| tbank | Владелец синхронизации T-Bank, scheduling/priority | [T-Bank data sync](openspec/specs/tbank/tbank-data-sync/spec.md) |
| tbank | Часовой обход, результат последней попытки | [Hourly investment sync](openspec/specs/tbank/hourly-investment-sync/spec.md) |
| brokerage | Счета/сводки | [Brokerage accounts](openspec/specs/brokerage/brokerage-accounts/spec.md) |
| brokerage | Операции/cursor/filters | [Account ledger](openspec/specs/brokerage/brokerage-account-ledger/spec.md) |
| brokerage | Будущие выплаты | [Upcoming events](openspec/specs/brokerage/brokerage-account-upcoming-events/spec.md) |
| brokerage | Доходы и периоды | [Income analytics](openspec/specs/brokerage/brokerage-account-income-analytics/spec.md) |
| portfolio | Portfolio positions/totals/stale | [Portfolio](openspec/specs/portfolio/portfolio-view/spec.md) |
| portfolio | Вкладка «Концентрация»: топ-5 акций/облигаций/секторов/эмитентов | [Portfolio concentration](openspec/specs/portfolio/portfolio-concentration/spec.md) |
| portfolio | История стоимости Portfolio | [Portfolio value history](openspec/specs/portfolio/portfolio-value-history/spec.md) |
| instruments | Справочные сведения/поиск инструментов (online-only) | [Online instrument information](openspec/specs/instruments/online-instrument-information/spec.md) |
| instruments | Календарь купонов/дивидендов инструмента | [Instrument income schedules](openspec/specs/instruments/instrument-income-schedules/spec.md) |
| instruments | Скринер акций | [Stock screener](openspec/specs/instruments/stock-screener/spec.md) |
| banking | Ручные дебетовые карты | [Manual bank cards](openspec/specs/banking/manual-bank-cards/spec.md) |
| frontend-platform | Каркас приложения, маршруты MVP | [App shell](openspec/specs/frontend-platform/app-shell/spec.md) |
| frontend-platform | Shared tokens/theme | [Design system](openspec/specs/frontend-platform/frontend-design-system/spec.md) |
| frontend-platform | Каталог компонентов | [UI catalog](openspec/specs/frontend-platform/frontend-ui-catalog/spec.md) |
| frontend-platform | Loading/skeleton | [Loading presentation](openspec/specs/frontend-platform/frontend-loading-presentation/spec.md) |
| frontend-platform | UI error/recovery | [UI resilience](openspec/specs/frontend-platform/frontend-ui-resilience/spec.md) |
| frontend-platform | Тихое чтение и редкие уведомления | [User feedback](openspec/specs/frontend-platform/frontend-user-feedback/spec.md) |
| frontend-platform | Query coherence/cache | [Query coherence](openspec/specs/frontend-platform/frontend-query-coherence/spec.md), [data resilience](openspec/specs/frontend-platform/frontend-data-resilience/spec.md) |
| frontend-platform | Breadcrumb/navigation | [Breadcrumbs](openspec/specs/frontend-platform/app-breadcrumb-navigation/spec.md) |
| frontend-platform | Structural boundaries/bundle size/coverage | [Quality guardrails](openspec/specs/frontend-platform/frontend-quality-guardrails/spec.md) |
| backend-platform | `/api/v1`, CORS, безопасные ошибки | [HTTP API foundation](openspec/specs/backend-platform/http-api-foundation/spec.md) |
| backend-platform | OpenAPI generation | [API contract generation](openspec/specs/backend-platform/api-contract-generation/spec.md) |
| backend-platform | Logging/request context | [Structured logging](openspec/specs/backend-platform/backend-structured-logging/spec.md) |
Active changes и archives сохраняют историю прежнего SDD workflow. Они не
являются обязательной процедурой текущей задачи. Для явно запрошенной работы
с OpenSpec используй [отдельный вход](AGENTS.md).
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## [0.2.1](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.2.0...v0.2.1) (2026-11-06)
### Specifications
* align Events and CURRENCY portfolio behavior (WealthLedger/specs[#60](https://git.personal.ksv741.ru/WealthLedger/specs/issues/60)) ([142f7aa](https://git.personal.ksv741.ru/WealthLedger/specs/commit/142f7aa225b4c9ca478ca9b350649df6746d6bf4))
* allow warning and retry after exhausted read retries (WealthLedger/specs[#79](https://git.personal.ksv741.ru/WealthLedger/specs/issues/79)) ([#80](https://git.personal.ksv741.ru/WealthLedger/specs/issues/80)) ([0469756](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0469756d3bc983e32811470b671d743cd5009739))
* treat MOEX response without requested block as source failure (WealthLedger/specs[#76](https://git.personal.ksv741.ru/WealthLedger/specs/issues/76)) ([#77](https://git.personal.ksv741.ru/WealthLedger/specs/issues/77)) ([aa1a57a](https://git.personal.ksv741.ru/WealthLedger/specs/commit/aa1a57ac202eed8352ffed29296432afca56a3e9))
### Refactoring
* group canonical specs by domain and archive account-top-positions ([#74](https://git.personal.ksv741.ru/WealthLedger/specs/issues/74)) ([3821d9e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/3821d9ed587e71e3da662e9b84f0c27bd32ce977))
### CI
* check whitespace in committed PR/push range (WealthLedger/specs[#2](https://git.personal.ksv741.ru/WealthLedger/specs/issues/2)) ([7f73de7](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7f73de76ac0e54327745cad61043922047c421ec))
* run native OpenSpec and local link validation in verify (WealthLedger/specs[#2](https://git.personal.ksv741.ru/WealthLedger/specs/issues/2)) ([1fd7e6a](https://git.personal.ksv741.ru/WealthLedger/specs/commit/1fd7e6ad974193eca3390cf41749368c5c318858))
### Maintenance
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/specs/issues/6)) ([#81](https://git.personal.ksv741.ru/WealthLedger/specs/issues/81)) ([8fc8e0a](https://git.personal.ksv741.ru/WealthLedger/specs/commit/8fc8e0a750a561b54251add5bac03d5ef58a94e3))
* drop OpenSpec CLI version pin and strict validation (WealthLedger/specs[#67](https://git.personal.ksv741.ru/WealthLedger/specs/issues/67)) ([#78](https://git.personal.ksv741.ru/WealthLedger/specs/issues/78)) ([8b2b742](https://git.personal.ksv741.ru/WealthLedger/specs/commit/8b2b742e52773ee3ab21fafaf6043420927640ac))
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.6...v0.2.0) (2026-11-01)
### Features
* add experimental wealth-ledger-sdd-lab OpenSpec schema ([#61](https://git.personal.ksv741.ru/WealthLedger/specs/issues/61)) ([322ddf6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/322ddf6a7f9b3309a875e17ee37a574c8decb1b0))
### Specifications
* archive group-account-rows-by-instrument and sync canonical specs (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([#65](https://git.personal.ksv741.ru/WealthLedger/specs/issues/65)) ([a2d2df3](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a2d2df3b5a7fc7f72bdd82ab42bb0ae25debfffe))
* plan grouping of all-account rows by instrument (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([#64](https://git.personal.ksv741.ru/WealthLedger/specs/issues/64)) ([f3a6b78](https://git.personal.ksv741.ru/WealthLedger/specs/commit/f3a6b78461408083f0ad04bdfe64634c09f53be2))
* record account-top-positions planning and implementation (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/specs/issues/48)) ([#69](https://git.personal.ksv741.ru/WealthLedger/specs/issues/69)) ([c66a5cd](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c66a5cd962fd265c3365e8c396a7958ba307bac0))
### Refactoring
* make sdd-lab review-driven (WealthLedger/specs[#66](https://git.personal.ksv741.ru/WealthLedger/specs/issues/66)) ([b9ec11f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b9ec11f5ba193dc851c79908c5ed8accc34e2e54))
## [0.1.6](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.5...v0.1.6) (2026-10-24)
### Maintenance
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/specs/issues/122)) ([c5b9cad](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c5b9cadbbd2dfbb6f139bc71851117d81c0e7c6d))
* **deps:** update non-major dependencies (WealthLedger/specs[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([08646ff](https://git.personal.ksv741.ru/WealthLedger/specs/commit/08646ff505eed501e54314c07ca4d1076a4c37dd))
## [0.1.5](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.4...v0.1.5) (2026-10-18)
### Specifications
+20 -13
View File
@@ -18,23 +18,30 @@ OpenSpec применяется после явного решения Change Ow
Для native validation из этого репозитория:
```bash
npm run check:openspec
openspec schema validate wealth-ledger-sdd --verbose
openspec schema validate wealth-ledger-sdd-v2 --verbose
openspec status --change <change> --store wealth-ledger-specs
openspec validate <change> --type change --strict --store wealth-ledger-specs
openspec validate --specs --strict --store wealth-ledger-specs
npm run validate
```
OpenSpec CLI ставится глобально и не закрепляется в `package.json`: его транзитивная
зависимость `braces` имеет advisory без исправления в 3.x. Ожидаемая версия хранится
в `openspec/.cli-version`; `npm run check:openspec` проверяет Node из `.nvmrc`, версию
CLI и доступность store, и при расхождении выводит команду исправления. Structural
validation не заменяет semantic verification и independent read-only review перед archive.
Одна команда, её же вызывает CI `verify`: `openspec schema validate` (все schemas),
`openspec validate --all` (specs и active changes) и [lychee](https://lychee.cli.rs/) 0.24.2 `--offline
--include-fragments` для относительных ссылок и anchors в корневых `*.md`
(включая `AI_INDEX`) и `openspec/specs`. Архивы и внешние URL не проверяются.
В чистой среде один раз: `openspec store register .`; lychee ставится
отдельно (например, `brew install lychee`). Для отдельного change:
`openspec status --change <change> --store wealth-ledger-specs`.
OpenSpec CLI ставится глобально (`npm install -g @fission-ai/openspec`) и не закрепляется
ни в `package.json` (его транзитивная зависимость `braces` имеет advisory без исправления
в 3.x), ни отдельным файлом версии: CI ставит актуальный выпуск. `validate` не использует
`--strict`: с 1.15 предупреждение о длинных требованиях (>500 символов) считается ошибкой
в strict-режиме. Structural validation не заменяет semantic verification и independent
read-only review перед archive.
Node задаётся собственной `.nvmrc`; после `nvm use --silent` выполните `npm ci`.
`npm run verify` проверяет diff. OpenSpec validation остаётся отдельной проверкой
при изменениях требований. История релизов — [CHANGELOG](CHANGELOG.md).
`npm run verify` локально проверяет whitespace в незакоммиченном diff; CI `verify`
выполняет тот же `git diff --check` по коммитам PR (merge-base→head) или push
(before→after, для новой ветки — последний коммит). Это структурная проверка
whitespace. Ни whitespace, ни structural validation не заменяют semantic review.
История релизов — [CHANGELOG](CHANGELOG.md).
## Релизы
-1
View File
@@ -1 +0,0 @@
1.13.1
@@ -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>&lt; 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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-копии; не блокирует.
@@ -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** сравнение с индексом недоступно с причиной «история индекса недоступна», а остальные разделы счёта работают без изменений
@@ -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 показана только отметкой на конце периода, а под графиком есть пояснение об отсутствии линии прошлой стоимости
@@ -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 и прототипе текст обновлён: вывод
бумаг без оценки так же искажает расчёт.
@@ -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' }
@@ -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
@@ -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.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd-lab
created: 2026-10-29
@@ -0,0 +1,216 @@
# Engineering Design
## Inputs
- Change: `wealth-ledger-specs`, `openspec/changes/group-account-rows-by-instrument/`.
- Утверждённые входы: `proposal.md`, `ux-design.md`, `specs/brokerage-account-ledger/spec.md`, `specs/brokerage-account-upcoming-events/spec.md`, `specs/brokerage-account-income-analytics/spec.md` относительно change.
- Issue: `WealthLedger/main#47`; рабочая ветка specs: `feature/47-group-rows-by-instrument`. При различии формулировки ISIN в Issue и утверждённых входов используется identity Instrument из входов.
- Проверенные источники реализации: `frontend/src/shared/api/generated/schema.d.ts`, `frontend/AGENTS.md`, `frontend/.ai/architecture.md`, conventions `engineering.md`, `typescript-react.md`, `ui.md`, `data.md`, `testing.md`.
- Ниже пути `src/` и `packages/` относятся к `frontend/`; этот документ не изменяет код. UX остаётся владельцем flows, визуальных решений и a11y.
## Architecture Drivers
- `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`: точный ключ, минимум две записи, отсутствие изменений single-account.
- `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`: сохранение каждого денежного движения, точность и запрет ложного итога.
- `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`: серверный порядок и только показанные записи.
- `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`: дата вместе с `dateKind`, identity и точный тип.
- `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total`: неизвестный прогноз и неподтверждённая полнота не превращаются в ноль.
- `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`: ограничение превью до группировки.
- `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`: визуальная агрегация не заменяет записи или серверные аналитические итоги.
- Ограничения: существующие API и query lifecycle, локальный FSD profile, без новых runtime dependencies и без хранения производного состояния.
## Technical Goals / Non-Goals
- Реализовать одну чистую операцию группировки и переиспользуемую точную decimal-арифметику; предметные ключи остаются у владельцев сущностей/страницы.
- Сохранить число исходных записей, знаки, валюты, порядок, provenance и прежние ветки одиночных карточек.
- Не менять backend, фильтры, cursor protocol, аналитические totals, доступ, синхронизацию, URL или часовые пояса текущего отображения.
- Не вводить межстраничное накопление, поиск identity через сеть, валютную конвертацию, новый feature/widget или универсальный aggregation framework.
## Implementation Impact Map
| Area | Affected | Outcome or N/A reason |
| --- | --- | --- |
| main | Нет | Issue уже существует; workspace и tooling не меняются. |
| frontend | Да | Shared grouping/decimal, entity presentation, четыре page slices и EntryCard workspace package. |
| backend | Нет | DTO и envelope достаточны; endpoints и вычисления сервера остаются прежними. |
| docs | Да, после реализации | Уточнить AS-IS представление списков и границу клиентских итогов; точные страницы ниже. |
| specs | Да | Этот design; утверждённые proposal/specs/UX не переписываются. |
## Decisions
### D1. Одна чистая группировка, предметные ключи снаружи
Драйвер: `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`.
В `src/shared/lib/group-items.ts` разместить `groupItems<T>(items: readonly T[], keyOf: (item: T) => string | null)`.
Результат — discriminated union: single с исходным item либо group с key и исходными items (не менее двух).
Функция не знает DTO, денег, счетов, React и режима маршрута; `null` всегда оставляет самостоятельный single.
Map сохраняет позицию первого появления ключа; последующие совпадения добавляются к его записям без сортировки.
Вход не мутируется; ссылки на записи сохраняются, записи одного счёта не схлопываются. Сложность O(n), память O(n).
Три независимых реализации отклонены из-за дублирования инварианта; framework с reducers/plugins — избыточен.
### D2. Ключи соответствуют реально отображаемому дню
Драйверы: `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`; `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`.
| Список | День | Identity: первое непустое поле | Тип и дополнительные поля |
| --- | --- | --- | --- |
| Журнал и последние операции | Локальный календарный день `occurredAt` в timezone браузера | `instrumentUid`, затем `figi` | Исходный `type` без нормализации |
| Выплаты | Тот же локальный день `occurredAt` | `instrumentUid`, затем `figi` | Исходный `type` |
| События и их превью | `date`, отображаемая через `formatDate` в UTC | `instrumentUid`, затем `figi`, затем `isin` | Исходный `type` и `dateKind` |
`LedgerEntryRow` передаёт `formatSnapshotTime(occurredAt)` и в обычную карточку, и в executed PayoutCard; formatter содержит дату и время без `timeZone` (`src/shared/lib/display.ts:132–139`).
Добавить рядом day-only formatter с тем же timezone браузера: использовать календарные части, не полную строку с временем и не `occurredAt.slice(0, 10)`.
`formatDate` уже фиксирует UTC (`display.ts:19–38`); для date-only события его ISO-дата достаточна как ключ.
`moscowDate` из `src/shared/lib/calendar.ts` обслуживает периоды/границы фильтров; не подменяет день отображения выплат.
Identity кодируется парой namespace/value; весь ключ — сериализованный tuple дня, identity, type и, для события, dateKind.
`account.id`, валюта, category, ticker, description и logo не входят в ключ. Пустая identity возвращает null.
`IncomeAnalyticsEntryDto.instrumentKey` — opaque membership аналитики, не доказанная identity Instrument: не использовать как fallback и не парсить.
Это подтверждает existing test `AccountIncomePage.test.tsx`, «opaque membership rather than instrument identity»: один instrumentKey допускает разные UID.
Сопоставление UID одной записи с FIGI другой через словарь aliases не вводится: нет авторитетного локального соответствия; одинаковый ticker не доказывает одну бумагу.
### D3. Точное сложение и отдельная политика допустимости итога
Драйверы: `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`.
В `shared/lib` сейчас есть string-formatting, проверка нуля и BigInt-округление, но нет экспортированного exact add.
Однако `src/pages/account/model/positions.ts:41–64,170–179` уже содержит decimal parse/serialize и точную сумму scaled BigInt.
Выделить только эту арифметику в `src/shared/lib/decimal.ts`, перевести существующего owner на неё; расчёт процентов и округление портфеля оставить локальными.
Публичная минимальная операция `sumDecimalStrings(values)` возвращает decimal string; внутри выравниваются scales и складываются signed BigInt, без Number/parseFloat и без округления.
Предусмотреть пустой ввод → `"0"`, канонический ноль без минуса, дробную часть любой поддержанной контрактом длины; не вводить библиотеку decimal.
Чистая `sumMoneyByCurrency` в shared принимает структурные `{ currency, value } | null`, возвращает список сумм либо недоступность; Map сохраняет первое появление валюты.
Если хотя бы один исходный amount отсутствует (а для журнала и «Выплат» также если он численно равен нулю), скрывается весь итог, а не только проблемная валюта. Некорректная decimal-строка также не превращается в ноль.
Только `meta?.completeness === "complete"` разрешает показ результата; отсутствие meta и `incomplete` запрещают его. Это predicate владельца ресурса, не арифметики.
Для ledger/income источник — только `payment`, не price/quantity/commission; для событий — `projectedAmount`, null означает неизвестный прогноз, включая offer.
Компенсация ненулевых слагаемых до нуля допустима: `+100 + (-100)` даёт видимый нейтральный ноль, в отличие от нулевого исходного amount.
Финальное представление — `formatMoney` с `withSign: true`, без `withRoundedCents`; серверные `totalsByCurrency`, buckets и instruments не пересчитываются.
### D4. Граница группировки — текущая выборка, не query cache
Драйверы: `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`; `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`.
`src/entities/broker-operation/model/useBrokerageOperationHistory.ts` хранит стек cursors и читает одну страницу: `onNext` добавляет cursor, `onPrevious` снимает его.
Это previous/next pagination, не infinite append: группировать только текущие `operations.data.items`; не объединять cached pages.
Тем самым «собирается только из показанных записей» выполняется буквально; `hasNext` не означает partial и не скрывает итог текущей группы.
Превью операций получает `limit: 5` в `AccountPage.vm.ts`; событий — `slice(0, RECENT_EVENTS_LIMIT)` в `pages/account/model/previews.ts`.
Группировать после этих ограничений; никогда не добирать шестую запись ради пары. При смене периода/счёта/cursor результат вычисляется заново.
### D5. Композиция вместо новых разновидностей каждой карточки
Драйверы: `brokerage-account-ledger` — `Full ledger presents compact rows`; `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`.
Общий недоменный `EntryGroup` в `src/shared/ui/EntryGroup/` принимает готовый header, accessible label и children; он владеет только композицией группы.
Предметные `LedgerEntryGroup` и `LedgerAccountRow` принадлежат `entities/broker-operation/ui`; `BrokerageEventGroup` и `BrokerageEventAccountRow` — `entities/brokerage-event/ui`.
Account row лёгкий, без вложенного полноценного LedgerEntryRow/Avatar; у выплат его существующие данные отображаются в карточном варианте, у остальных — строковом.
В `packages/ui/src/EntryCard/EntryCard.tsx` расширить только value slot с string до ReactNode (включая null); сохранить string callers и прежний DOM для них.
Слот допускает несколько самостоятельно оформленных валютных значений и отсутствие видимого значения; общий tone карточки не должен перекрашивать все валюты одним знаком.
Для одной внешней рамки использовать существующий `details` slot EntryCard под содержимое группы, не вкладывать вторую Paper вокруг целой карточки.
Singleton продолжает вызывать `LedgerEntryRow` либо `BrokerageEventCard`, а через них существующий `PayoutCard`; его API не расширяется ради grouping.
Альтернативы — копировать EntryCard или добавлять group/account варианты в PayoutCard — увеличивают дублирование либо смешивают ответственности.
### D6. Явный all-mode на границе page
Драйвер: `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`.
Page VM читает route param и передаёт `isAllAccounts = accountId === "all"`; это существующий sentinel, например `pages/account/ui/AccountPage/components/AccountHeading.view.tsx`.
При false сохраняется прежний flat-render path без вызова группировки и новой политики сумм. Наличие account у записи не используется как определение режима.
Не извлекать режим из URL внутри entity, имени счёта, количества счетов или query result.
## Components and Responsibilities
| Owner / путь | Ответственность и направление зависимостей |
| --- | --- |
| `shared/lib/group-items.ts`, `decimal.ts` | Недоменная чистая механика; экспорт через `shared/lib/index.ts`. |
| `entities/broker-operation/model/display.ts` | Ключ операции и group presentation; reuse `paymentPresentation`, `isIncomePayment`, `operationLabelKey`, а не category как type. |
| `entities/brokerage-event/model/event-key.ts` | Добавить отдельный grouping key; `brokerageEventKey` оставить ключом исходного события, не заменять им группировку. |
| `entities/*/ui/*Group`, `*AccountRow` | Преобразование предметных данных в shared UI; без queries и зависимости сущностей друг от друга. |
| `pages/account-income/model/display.ts` | Сохранить occurrence keys каждой выплаты, сформировать grouped sequence только в all; income DTO структурно подходит operation renderer. |
| `pages/account-operations`, `pages/account-events` | VM передаёт all и подтверждение полноты; loaded view выбирает single/group. |
| `pages/account/model/previews.ts` | Передать all/completeness каждого собственного ресурса после ограничения записей. |
| `RecentOperations.view.tsx`, `BrokerageEventsPreview.tsx` | Композиция preview single/group; состояния блоков не подменяют основной портфель. |
Импорты через public `index.ts`; page не импортирует соседнюю page. Компонент с тестом/CSS живёт в `Component/Component.tsx`, `.test.tsx`, `.module.css` по UI conventions; чистые тесты colocated `.test.ts`.
## Data / Storage Changes
N/A: новые данные не сохраняются. Группы и BigInt существуют только при вычислении presentation; ни localStorage, ни второй query cache, ни миграции БД не нужны.
## Contract Implementation
N/A для изменения контракта: проверены следующие существующие поля `src/shared/api/generated/schema.d.ts`.
| DTO | Счёт / дата / тип | Identity | Деньги |
| --- | --- | --- | --- |
| `BrokerageOperationDto` (1087) | `account?`, `occurredAt`, `type` | nullable `instrumentUid`, `figi`; ISIN нет | nullable `payment`, `price`, `accruedInterest`, `commission` |
| `UpcomingEventDto` (1229) | `account?`, `date`, `dateKind`, `type` | nullable `instrumentUid`, `figi`, `isin` | nullable `projectedAmount` |
| `IncomeAnalyticsEntryDto` (1158) | `account?`, `occurredAt`, `type` | nullable `instrumentUid`, `figi`, opaque `instrumentKey` | nullable `payment` и остальные поля операции |
`MoneyDto` содержит string `value` и `currency`; resource envelopes несут meta. Полей достаточно для алгоритма без нового endpoint, enrichment и ручных правок generated types.
Optional `account` нужен для single-account DTO; all-response должен фактически содержать `{id,name}` согласно `docs/domains/portfolio/all-accounts.md`. Типы сами эту гарантию не доказывают: проверить fixture/response на consumer boundary.
При отсутствующем account не угадывать происхождение и не строить ссылку из `all`; это нарушение данных, а не повод менять API в данном change.
## Cross-cutting Implementation
`src/shared/api/resource-query.ts:128–147` раскрывает data и meta одного envelope. Entity hooks и useScreenSync остаются владельцами загрузки и обновления.
Operations/events VM уже передают `source: query.meta` и `partial`; для итога использовать source.completeness, не инверсию partial.
Income VM сейчас передаёт только `partial: analytics.meta?.completeness === "incomplete"`; добавить явный `totalsAllowed` от meta и провести через loaded view в IncomePayments.
`previews.ts` теряет подтверждение complete при сведении к loaded/partial: добавить `totalsAllowed` отдельно для operations/events; meta портфеля здесь не подходит.
Stale/refresh failure не равны incomplete: сохранённые данные продолжают отображаться по прежней policy; новая группа не хранит собственный snapshot.
React keys групп — tuple-key; строки — прежняя identity с account и occurrence для повторений. Не дедуплицировать совпадающие события; индекс допустим лишь как occurrence внутри идентичного ключа.
Переводы и озвучивание остаются в presentation; shared арифметика не импортирует i18n. Новых telemetry, запросов и зависимостей нет.
## UI Implementation
Композиция: route → existing query/VM → текущие ограниченные items + all + totalsAllowed → pure grouping → entity single/group → shared EntryCard/EntryGroup.
Производные группы не помещаются в useState/effect; существующие состояния запроса и pagination остаются источником истины.
Ниже привязка к состояниям `ux-design.md#ui-states`, без переопределения их представления:
| UX state | Как производится |
| --- | --- |
| Одиночная запись | Single ветка результата или прежний single-account path. |
| Группа с итогом / мультивалютный итог | Два и более items; totalsAllowed; успешная сумма по валютам, переданная value slot. |
| Итог скрыт | Неподтверждённая completeness либо недопустимый amount; null visual value и перевод для accessible name. |
| Нулевой итог | Все слагаемые допустимы, точная сумма равна нулю; neutral presentation без знака. |
| Offer без сумм | Null projectedAmount; event-row использует существующий `events.noAmount`. |
| Прогноз | Event presenter использует `payout.forecast`, независимо от знака и числа валют. |
| Строка без денег | Existing payment semantics, нулевой/null amount; group total недоступен. |
| Обзор, 5 записей / граница страницы | D4; состав определяется до группировки, никаких additional fetch. |
| Loading / empty / error / partial-Alert | Existing VM/view guards; группировка только доступного массива, прежние alerts и SourceSyncStatus сохраняются. |
В all-ветке контейнеры loaded lists получают list semantics из UX; account-row использует данные счёта, не делает lookup через другой entity.
Подписи/дата/лого заголовка берутся из предметного presenter первой записи; у событий dateKind общий благодаря ключу.
EntryCard value slot и CSS module EntryGroup реализуют responsive/a11y контракт UX; отдельный локальный state раскрытия не создаётся.
## Developer Verification Strategy
Test Intent: доказать сохранение записей и денежных значений при изменении только композиции; риск — ложный итог, смешение типов/дней, межстраничная агрегация.
- Unit grouping: пустой список, одиночный ключ, null identity, не соседние совпадения, один счёт дважды, порядок первого появления и children, отсутствие мутации.
- Unit key adapters: разные type внутри одной category; UID/FIGI/ISIN priority; отсутствие identity; разный dateKind; одинаковый день с разным временем, граница суток в разных timezone.
- Unit decimal/money: разные scales, отрицательные дроби, значения больше safe integer, `0.1 + 0.2`, несколько валют, точная компенсация до нуля, null/zero/negative-zero, malformed input, пустой ввод.
- Regression выделенной арифметики: существующие `pages/account/model/positions.test.ts` и display tests проходят без изменения портфельных результатов.
- Component/route tests: все четыре поверхности all, singleton и single-account, account provenance, partial и отсутствующая meta, complete zero, unknown forecast; серверные income totals неизменны.
- Pagination через MSW: next/previous, тот же ключ на двух страницах, reset фильтра и invalid cursor; после перехода нет строк предыдущей страницы.
- Preview test: пять исходных записей, шестая не подтягивается; UI group не считается одной записью лимита. React keys не теряют повторные выплаты.
- RTL semantic queries проверяют nested lists, доступные имена и отсутствие ложного итога; browser proof — переносы, 320 px, 200% zoom и focus согласно UX, не проверки CSS через JSDOM.
- После загрузки `$HOME/.nvm/nvm.sh` и `nvm use --silent` из frontend: адресный `npm run test:run -- <path>`, `npm run typecheck`, `npm run lint`; на итоговом code diff — один `npm run verify`.
- Verify уже включает offline `openapi:check`, coverage, Knip, dependency boundaries, app/catalog build; отдельный runner или CI pipeline не добавлять. Для одного этого Markdown — semantic review и `git diff --check`, без application build.
## Migration / Rollout / Rollback
Сначала выделить арифметику с regression checks; затем pure grouping и entity composition; затем подключить all-ветки и провести meta до потребителей.
Существующие EntryCard callers сохраняют совместимость; выпуск возможен одним frontend build без координации backend или миграции cache/data.
Откат — предыдущий frontend build либо возврат flat-render all-веток; серверные данные и API не затронуты, feature flag ради этого change не нужен.
## Solution Assessment
N/A: нет новых auth, storage, integrations, caching, concurrency или shared tooling; изменение локально и обратимо. Риски точной арифметики и UI описаны проверками выше.
## Risks / Trade-offs
- В UX есть неоднозначность: Accessibility требует account links в строках вообще, Presentation Decisions п. 4 исключает обзор/события. До реализации ссылок владелец UX должен подтвердить область применения; дизайн её молча не меняет.
- UTC календарь событий, browser timezone исполненных записей и Moscow период аналитики различаются уже сейчас; намеренно не исправляем их этим change.
- Отсутствие общей identity или account нельзя восстановить из названия; проверка фактических all fixtures обязательна. instrumentKey не устраняет этот риск.
- Canonical «Account page presents an upcoming events list» говорит «без фильтров», а страница уже фильтрует период. Этот drift существовал до change и записан в WealthLedger/specs#63; фильтры в рамках группировки не трогаются.
## ADR Candidates
Отдельный ADR не требуется: API, архитектурный стиль и технологии не меняются; локальные обратимые решения D1–D6 достаточно сохранить в этом change.
## Docs Impact
Цели и итоговый AS-IS перечислены в разделе `## Docs Impact` в tasks.md, docs-impact не выбран. `docs/design/overview.md` не меняется.
@@ -0,0 +1,64 @@
# Process Record
**Mode:** lab
**Class:** standard
**Issue:** https://git.personal.ksv741.ru/WealthLedger/main/issues/47
## Change Profile
| Artifact | Selected | Reason |
| --- | --- | --- |
| research | no | Это потребность самого Change Owner, а as-is уже разобран и записан в Proposal Evidence. Рынок и альтернативы изучать не нужно. |
| ux-design | yes | Trigger: на четырёх экранах режима «все счета» появляется новый паттерн представления (заголовок группы, итог, разбивка по счетам), и он должен уживаться с существующими карточками событий и операций. |
| system-analysis | no | Не trigger: межсистемный contract, data ownership и интеграции не меняются. Это представление уже полученных данных внутри frontend. |
| data-mapping | no | Модуль требует system-analysis, а она не выбрана. Ключ группы, правило итога и запрет смешивания валют задаются Requirements в specs и проверяются в test-cases. |
| failure-model | no | Нет внешней интеграции, фоновой синхронизации и многошаговой записи. Состояния загрузки и ошибок списков не меняются. |
| nfr | no | Группировка охватывает уже загруженные строки списка. Значимых ограничений по performance, availability или capacity нет. |
| observability | no | Клиентское представление без фоновых процессов. Оператору нечего отличать. |
| security-analysis | no | Модель доступа, trust boundaries и состав раскрываемых данных не меняются: показываются те же записи текущего User. |
| test-cases | yes | Trigger: денежные итоги групп, граничные случаи ключа (тип, дата, отсутствие Instrument, один счёт, разные валюты) и стык загруженных страниц. Для их проверки нужны конкретные данные и oracle. |
| docs-impact | no | Обновляются только AS-IS страницы доменов (4 файла в `docs/domains/`). ADR, architecture- и integration-страниц нет. Цели перечислены в `## Docs Impact` в tasks.md. |
## Gate Log
| Gate | Date | Scope | Validation | Findings | Decision |
| --- | --- | --- | --- | --- | --- |
| G1 | 2026-10-29 | proposal, change-profile | `check.cjs plan` OK; `openspec validate` — ожидаемая ошибка «no deltas» до specs; G1 review product-analyst | 2 blocker, 5 major, 1 minor — закрыты решениями владельца в Proposal | approved "все ок, идем дальше" |
| G2 | 2026-10-29 | specs | `openspec validate --strict` valid; `check.cjs plan` OK; G2 review qa-engineer + independent-reviewer | 2 blocker, 6 major, 8 minor — закрыты правками specs и решением владельца (итог скрыт при `partial`) | approved "да, все ок, идем дальше" |
| G2 | 2026-10-29 | specs | — | UX нашёл 2 пробела в specs: итог при неполном прогнозе; нулевая сумма | invalidated: решения владельца в текущей сессии («Скрывать, как в журнале», «Скрывает итог») изменили Requirements об итоге группы журнала и событий |
| G2 | 2026-10-29 | specs | `openspec validate --strict` valid; `check.cjs plan` OK | Правки по 2 UX findings внесены в specs | approved "да, все ок" |
| G3 | 2026-10-29 | design, ux-design, qa-analysis, test-cases, tasks | `openspec validate --strict` valid; `check.cjs plan` planning complete; `git diff --check` clean; G3 review independent-reviewer | 3 major, 5 minor — исправлены | approved "да, давай дальше" |
| G4 | 2026-10-29 | implementation, review, QA, docs | frontend `npm run verify` exit 0 (530 tests, coverage 92.35%); docs `npm run verify` OK; independent review; QA в браузере (18 TC/сценариев pass); openspec-verify-change: 0 critical, все warnings закрыты; `check.cjs apply` OK | Review 3 major + 5 minor, verify 2 warning + 2 suggestion; всё исправлено. Flaky `screen-sync.test.tsx` есть и до change, файл не менялся | approved "да, все ок, давай коммититься" |
## Descoped Work
| Task | Reason | Gate |
| --- | --- | --- |
## Lab Journal
| Stage | Role / subagent | Unique findings | Decisions | Risks found / excluded | Handoff issues | Skills | Cost | Transferable |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| proposal + change-profile | product-analyst (G1 review) | Режим «Все счета» потерян в canonical ledger/income; запрет frontend-пересчёта итогов в «Выплатах»; неоднозначности даты и типа, порядка, лимита превью | 7 решений Change Owner внесены в Proposal | blocker ×2, major ×5, minor ×1; все закрыты | Ответ сабагента дважды обрезался, пришлось запрашивать продолжение | openspec-new-change | 3 вызова сабагента | Делать G1 review до показа владельцу: он нашёл canonical drift |
| specs | qa-engineer, independent-reviewer (G2 review) | Затронутые canonical блоки (compact rows, обзор, список событий без итога) нужно было модифицировать; восстановление `all` было неполным относительно archive; конфликт итога с frontend-data-resilience; ключ «точный тип» неоднозначен для покупки и продажи | Итог скрывается при `partial` (решение владельца); граница страницы — явное исключение; блоки восстановлены из archive целиком | 2 blocker, 6 major, 8 minor; все закрыты правками. Вне scope: canonical drift persist-tbank-data шире двух восстановленных блоков | Ответы сабагентов трижды обрезались | openspec-new-change | 4 вызова сабагентов | Ревьюеру консистентности давать список canonical блоков, которые затрагивает UI-изменение |
| design + ux-design + qa-analysis + test-cases + tasks | ux-designer, solution-architect, qa-engineer, independent-reviewer (G3 review) | UX: 2 пробела в specs (неполный прогноз, нулевая сумма), из-за них G2 переутверждён. Design: `instrumentKey` не является identity, у журнала пагинация назад/вперёд, а не дописывание, BigInt-decimal уже есть в `positions.ts`. Review: test-cases ссылались на несуществующие сценарии, неверные oracle в TC-PAG | Ссылки на счёт оставлены текстом, как сейчас; лимит превью считается по записям; Docs Impact перенесён в tasks | G3 review: 3 major, 5 minor, все исправлены. Вне scope: drift с фильтрами событий записан в specs#63 | qa-engineer сначала выдал 46 TC вместо case-only; сабагенты обрезались | — | 7 вызовов сабагентов | Давать QA правило «case только сверх Scenario» сразу; проверять ссылки на сценарии через grep |
| apply + G4 checks | frontend-engineer, documentation-engineer, independent-reviewer, qa-engineer | Review: имя группы для скринридера неполное, у выплат терялись occurrence keys, выплаты в группе не были карточками, ссылки на счета появились вопреки UX. QA: 18 проверок pass в браузере со stubbed API | Все findings исправлены. QA D1 (заголовок «Дивиденд») — артефакт заглушки с `type: "Купон"` вместо `OPERATION_TYPE_COUPON`, не дефект. D2 опровергнут кодом: `aria-label` стоит на вложенном `ul` по UX | Flaky `screen-sync.test.tsx` есть и до change, файл не менялся; на baseline 3/3 pass | Исполнитель дважды молча отклонился от UX; поймано review | — | ~9 вызовов сабагентов | Давать исполнителю явный список Presentation Decisions; QA строить заглушки только из enum generated types |
| verify (openspec-verify-change) | основной агент | Строка записи в группе журнала показывала дату вместо подписи типа (расхождение с `Full ledger presents compact rows`); у сценариев BUY/SELL, «итог в нескольких валютах» для событий и «оферта» не было автотестов | Исправлено: подпись типа в строке, `aria-label` у карточки-строки, 4 новых теста | 0 critical, 2 warning, 2 suggestion; все закрыты | — | openspec-verify-change | 1 проход | Verify по сценариям находит то, что пропустили review и QA |
## Retrospective
**Факты**
- 4 gate, 1 инвалидация G2: UX нашёл 2 пробела в specs (итог при неполном прогнозе, нулевая сумма).
- Независимый review находил реальные дефекты на каждом gate:
- G1: в canonical specs потерян режим `all`; спека «Выплат» запрещала frontend считать итоги.
- G2: группировка нарушала canonical requirements, которые change не модифицировал.
- G3: test-cases ссылались на несуществующие сценарии, а oracle пагинации были неверны.
- G4: имя группы для скринридера, повторные ключи выплат, ссылки на счета вопреки UX.
- Verify по сценариям нашёл расхождение, которое пропустили review и QA: в строке группы не было подписи типа.
- Побочный результат — WealthLedger/specs#63: canonical drift после archive `persist-tbank-data`.
- Стоимость: около 25 вызовов сабагентов. Ответы часто обрезались, так что прятались продолжения.
**Оценка**
- Окупились: G1/G2 review (без них delta строилась бы на неполном каноне), ux-design (нашёл пробелы в specs), verify по сценариям.
- Избыточным для standard UI-change оказался первый вариант QA-артефактов: 46 TC вместо cases «сверх Scenario». Пришлось переписать.
- Изменить в schema: в instruction для qa-analysis/test-cases явно требовать ссылки на существующие `#### Scenario` и проверку их grep-ом. В brief для исполнителя передавать Presentation Decisions из ux-design как обязательный список.
@@ -0,0 +1,79 @@
# Proposal
Issue: WealthLedger/main#47
## Why
В режиме «Все счета» события и операции показываются плоским списком: одна строка — одна запись одного счёта. Одна и та же бумага часто есть сразу на брокерском счёте и на ИИС. В этом случае выплаты по ней в один день разбросаны по отдельным строкам, и пользователю приходится самому связывать их и сравнивать суммы по счетам.
## What Changes
- В режиме «Все счета» записи с одним днём, одним Instrument и одним точным типом события или операции объединяются в одну визуальную группу.
- В заголовке группы — бумага, тип и итог. Итог — точная сумма записей группы, отдельно по каждой валюте и со знаком. Под заголовком всегда раскрыта разбивка: каждая запись остаётся отдельной строкой со своим счётом и суммой (например, «Брокерский — 200 ₽», «ИИС — 100 ₽»).
- Группировка применяется к предстоящим событиям и к исполненным операциям любой категории, если у записи есть identity Instrument.
- Группа образуется из двух и более записей с одинаковым ключом, даже если все они относятся к одному счёту.
- Группа стоит на месте своей первой записи в серверном порядке. Записи внутри группы и остальные строки сохраняют относительный серверный порядок.
- Режим одного счёта, записи без Instrument и одиночные записи не меняются.
- В нормативные specs журнала и аналитики дохода возвращается уже существующий режим «Все счета». Он был утрачен при синхронизации canonical specs; поведение при этом не меняется.
- Для «Выплат» в режиме «Все счета» вводится исключение из действующих запретов: группировочные карточки по Instrument и вычисление итога группы на клиенте разрешены. Аналитические итоги дохода по-прежнему приходят с сервера и не пересчитываются.
## Scope
### In Scope
- Страница предстоящих событий в режиме «Все счета».
- Страница журнала операций в режиме «Все счета».
- Превью ближайших событий и последних операций на обзоре в режиме «Все счета». Лимит считается по записям, как сейчас.
- Раздел «Выплаты» на странице доходов в режиме «Все счета».
- Восстановление в canonical specs журнала и дохода нормативного описания режима «Все счета» в его текущем поведении.
### Out of Scope / Non-Goals
- Режим одного счёта.
- Группировка за пределами одного дня, по эмитенту или между разными типами: покупка ≠ продажа, купон ≠ налог с купона, налог с купона ≠ налог с дивиденда.
- Изменение состава, фильтрации, серверного порядка, пагинации, лимитов превью или денежных значений, которые возвращает сервер.
- Записи без identity Instrument.
- Сворачивание групп и пометка, что группа может быть неполной.
- Аналитические итоги дохода и их пересчёт.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `specs/brokerage-account-upcoming-events/spec.md`: представление событий (страница и превью) в режиме «Все счета».
- `specs/brokerage-account-ledger/spec.md`: восстановление режима «Все счета», представление журнала и превью операций в этом режиме.
- `specs/brokerage-account-income-analytics/spec.md`: восстановление режима «Все счета», раздел «Выплаты» в этом режиме с исключением из запретов на группировку и клиентский итог.
## Impact
- Пользователи с несколькими счетами, которые смотрят режим «Все счета».
- Меняется только представление уже полученных данных. Источники данных, серверные ответы и интеграция с T-Bank остаются прежними.
- Вероятно затрагиваются клиентское представление списков и AS-IS документация экранов.
## Success Criteria
- Купоны по одной облигации за один день (200 ₽ на брокерском счёте и 100 ₽ на ИИС) в режиме «Все счета» видны одной группой с итогом 300 ₽ и отдельной строкой для каждого счёта.
- Записи разных типов или дней по одной бумаге не объединяются.
- Итог группы точен, показывается отдельно по каждой валюте и учитывает знак записей.
- В режиме одного счёта и для записей без Instrument списки выглядят как раньше.
## Evidence
- [F] В режиме «Все счета» страницы событий, операций, превью обзора и «Выплаты» показывают плоский список, по строке на запись, со счётом в каждой строке. Группировки по Instrument нет. Источник: AS-IS frontend, разбор кода 2026-10-29.
- [F] Canonical `brokerage-account-income-analytics` («Income payments remain traceable to exact Ledger Entries») запрещает группировочные карточки и subtotals по Instrument в «Выплатах» и пересчёт денежных итогов на frontend. Источник: canonical spec, archive `2026-10-14-unify-income-payment-cards`.
- [F] Режим «Все счета» для журнала и дохода описан в delta specs archive `2026-10-09-persist-tbank-data`, но отсутствует в текущих canonical specs `brokerage-account-ledger` и `brokerage-account-income-analytics`. В `brokerage-account-upcoming-events` он есть. Источник: specs store, G1 review 2026-10-29.
- [F] Потребность: одна облигация на брокерском счёте и ИИС, выплаты по ней в один день нужно видеть рядом с суммой по каждому счёту. Источник: запрос Change Owner, WealthLedger/main#47.
- [H] Сервер в режиме «Все счета» уже возвращает для каждой записи счёт, дату, тип, identity Instrument и точную сумму. Подтверждается в Design.
## Assumptions / Dependencies
- День записи берётся тот же, что строка уже показывает на этом экране. Часовой пояс отображения не меняется.
- Группа собирается только из записей, показанных на текущей странице журнала или в превью. При переходе на другую страницу группы собираются заново из её записей, поэтому группа на границе страницы может быть неполной.
## Open Decisions
Нет. Change Owner принял в текущей сессии: ключ (день как на экране, identity Instrument, точный тип), клиентский итог по валютам, позиция группы на месте первой записи, лимит превью по записям, неполная группа без пометки, строка на каждую запись, восстановление режима «Все счета» в этом change.
@@ -0,0 +1,89 @@
# QA Analysis
Объект проверки: frontend-only изменение группировки строк по Instrument в режиме «Все счета» (журнал, обзор, будущие события, «Выплаты»). Backend и API не меняются; контракт используется как есть.
## Risk Assessment
| Risk | Affected requirements | Likelihood | Impact | Verification depth |
| --- | --- | --- | --- | --- |
| Неверный ключ группы: объединение записей разных типов/дней или не-объединение одинаковых | ledger/upcoming `groups same-day…` | Средняя | Высокая | Полный перебор граничных комбинаций ключа (тип, день, Instrument, один счёт) |
| Неверный итог: конвертация/округление валют, потеря знака, итог при отсутствии/нулевой сумме, при `partial` | ledger/upcoming `group shows each entry and …` | Средняя | Высокая | Мультивалютные, знаковые, нулевые и запрещающие сценарии итога; сверка с серверными суммами |
| Нарушен серверный порядок или лимит (5 записей обзора, пагинация журнала) из-за группировки | `preserves order and limits` | Средняя | Средняя | Проверка позиций групп, границы страницы, состава «последних 5» |
| Группировка случайно применяется в режиме одного счёта или к записям без Instrument | `groups same-day…`, MODIFIED «Full ledger…» | Низкая | Высокая | Регрессионная проверка одиночного режима и записей без Instrument |
| «Выплаты»: группировка нарушает запреты одиночного режима или подменяет аналитические итоги | `Income payments remain traceable…` | Средняя | Высокая | Проверка обоих режимов, неизменности серверных аналитических итогов |
| Пропадание записей внутри группы (свернуто, скрыто) или потеря fallback при отсутствии metadata | `group shows each entry…`, MODIFIED `Full ledger…`, MODIFIED overview | Низкая | Высокая | Визуальная проверка разбивки без раскрытия, fallback-представление |
| Обзор «Все счета»: режим не открывается / принадлежность счёту потеряна (восстановление режима `all`) | MODIFIED `User can retrieve an account ledger`, MODIFIED overview, MODIFIED upcoming list | Средняя | Средняя | Проверка доступности и контента режима `all`, `partial`-поведение |
## Coverage Matrix
| Requirement | Check | Level | Test cases |
| --- | --- | --- | --- |
| `brokerage-account-ledger` — `User can retrieve an account ledger` | Режим `all` доступен; принадлежность записей; `partial`; пустая незагруженная часть | System / E2E | Scenario: Получение журнала с периодом по умолчанию; Scenario: Общий журнал сохраняет принадлежность записей; Scenario: Общий журнал содержит неполный счёт; Scenario: Пустая загруженная часть не доказывает отсутствие операций |
| `brokerage-account-ledger` — `Account overview includes recent ledger events` | Группы в обзоре «Все счета»; лимит 5; fallback без metadata | System / E2E | Scenario: Отображение последних событий; Scenario: Последняя запись не содержит visual metadata инструмента; Scenario: Группировка в последних записях обзора; TC-PAG-002, TC-PAG-003 |
| `brokerage-account-ledger` — `Full ledger presents compact rows` | Строка в группе: счёт, сумма, знак, подпись типа; заголовок группы; fallback | System / E2E | Scenario: Запись внутри группы режима «Все счета»; Scenario: Подпись типа не дублируется; Scenario: Отсутствует visual metadata инструмента; TC-GRP-001, TC-TOT-001 |
| `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument` | Ключ группы: два счёта, один счёт, разные типы, покупка/продажа, разные дни, без Instrument, один счёт | System / E2E | Scenario: Купоны одной облигации на двух счетах; Scenario: Две операции одного типа на одном счёте; Scenario: Разные типы по одной бумаге не объединяются; Scenario: Покупка и продажа не объединяются; Scenario: Разные дни не объединяются; Scenario: Запись без Instrument; Scenario: Режим одного счёта не меняется; TC-GRP-001, TC-GRP-002, TC-GRP-003, TC-GRP-004 |
| `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total` | Точный итог: мультивалюта, знак, ноль, без изменения, `partial`; разбивка всегда видна | System / E2E | Scenario: Итог по нескольким валютам; Scenario: Итог со знаком; Scenario: Нулевой итог; Scenario: Группа с записью без денежного изменения; Scenario: Запись с нулевой суммой скрывает итог; Scenario: Итог скрыт при неполном периоде; TC-TOT-001, TC-TOT-002, TC-TOT-003, TC-TOT-004 |
| `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits` | Позиция группы; граница страницы; лимит 5 обзора | System / E2E | Scenario: Позиция группы в списке; Scenario: Группа на границе страницы; TC-PAG-001, TC-GRP-002 |
| `brokerage-account-upcoming-events` — `Account page presents an upcoming events list` | Страница в `all`; групповой итог ≠ итог списка; дисклеймер | System / E2E | Scenario: Просмотр ближайших событий; Scenario: Пустое состояние страницы; Scenario: Прогнозный дивиденд с неизвестной суммой |
| `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument` | Группы событий: два счёта, разные типы/даты, без Instrument, один счёт | System / E2E | Scenario: Прогноз купонов одной облигации на двух счетах; Scenario: Разные типы или даты не объединяются; Scenario: Событие без Instrument; Scenario: Режим одного счёта не меняется; TC-GRP-005 |
| `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total` | Прогнозный итог: неизвестная сумма, оферта, мультивалюта, неполный прогноз, дата реестра | System / E2E | Scenario: Событие с неизвестной суммой в группе; Scenario: Оферта без сумм; Scenario: Прогнозный итог по нескольким валютам; Scenario: Прогнозный итог скрыт при неполном прогнозе; Scenario: Группа дивидендов по дате реестра; TC-GRP-005, TC-TOT-005 |
| `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits` | Позиция группы событий; ближайшие события обзора | System / E2E | Scenario: Позиция группы событий; Scenario: Группировка в ближайших событиях обзора; TC-PAG-002 (по аналогии для событий обзора) |
| `brokerage-account-income-analytics` — `Realized income analytics is account-scoped and period-bound` | Режим `all` в аналитике; полнота всех счетов; периоды | System / E2E | Scenario: Общий доход требует полноты всех счетов; Scenario: Открыт ещё не загруженный период (изменение frontend не затрагивает — smoke) |
| `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries` | «Выплаты»: одиночный режим без групп; группы в `all`; итог при неполноте; аналитика не пересчитана; корректировки и дубли ID | System / E2E | Scenario: Каждая выплата показана отдельной карточкой; Scenario: Список воспроизводит точный состав дохода; Scenario: Отрицательная корректировка и повторяющиеся IDs сохраняются; Scenario: Выплаты одной бумаги на двух счетах в режиме «Все счета»; TC-GRP-006, TC-TOT-004 (правило скрытия итога) |
## Negative and Boundary Focus
- Ключ группы: разные точные upstream-типы, покупка vs продажа, разные экранные дни, отсутствие identity Instrument, одна запись (нет пары), все записи на одном счёте.
- Итог: мультивалюта без конвертации; копеечные суммы без округления (+150.10 −50.05); нулевой итог без знака; запись без денежного изменения; нулевая сумма; `partial`-период; неполный прогноз; событие с неизвестной суммой; оферта без сумм.
- Порядок и лимиты: группа из записей первой и следующей страницы журнала; лимит 5 записей обзора (считается по записям, не по группам); позиция группы по первой записи.
- Режимы: один счёт — полное отсутствие группировки; записи без Instrument не сливаются.
## Cross-system Verification
system-analysis не выбран, backend и API в рамках change не меняются — отдельная кросс-системная проверка не требуется. Существующие серверные данные проверяются как вход: реальные ответы API (`{ data, meta }`, принадлежность записей счёту, `partial`-состояние, серверные аналитические итоги, порядок entries) используются как эталон, с которым сверяется клиентское представление (состав групп, суммы, порядок). Ключевой инвариант: аналитические денежные итоги и receipt count приходят только с сервера и не пересчитываются; единственный клиентский расчёт — итог визуальной группы, который сверяется с суммой серверных записей группы.
## Test Data and Environment Needs
Локальный frontend + локальный backend с сохранёнными данными (или stubbed API в браузере через devtools/сервис-воркер-заглушку). Фикстуры:
- Два счёта (брокерский + ИИС) с одной и той же облигацией: исполненные купоны в один день (+200 RUB и +100 RUB) — базовая группа.
- Мультипанель: одна группа с записями в RUB и USD; выплаты одной бумаги в разных валютах.
- Граничные суммы: +150.10 / −50.05 / −150.10; запись с нулевой суммой; запись без денежного изменения; отрицательная корректировка.
- Разнообразие типов: купон + налог в один день, покупка + продажа одной бумаги в один день, две покупки на одном счёте, купоны в разные (экранные) дни.
- Записи без identity Instrument (например, переводы) — два в один день одного типа.
- Записи без presentation metadata инструмента — fallback.
- `partial`: счёт с незагруженным интервалом в запрошенном периоде; пустая сохранённая часть при неполном покрытии.
- Граница страницы: настройки пагинации журнала так, чтобы записи одной группы попали на разные страницы; подтверждённо полный период.
- Будущие события: купон на двух счетах в одну дату; дивиденды по дате реестра (известная и неизвестная суммы); оферта на двух счетах; события без суммы; неполный прогноз.
- Обзор: ровно 5 последних записей, среди которых пара с одинаковым ключом.
- Режим одного счёта с теми же данными (два купона одной бумаги в один день).
Источник: подготовленные фикстуры в локальной БД тестового backend; при отсутствии — заглушка API-ответов в браузере с теми же контрактными envelope.
Примечания по окружению (не блокеры): тестовые данные границы страницы строятся вокруг фактического размера страницы, который возвращает сервер (TC-PAG-001 фиксирует конкретное содержимое страниц в момент прогона); состояние `partial` и неполный прогноз воспроизводятся заглушкой API-ответа в браузере (MSW / Playwright route) с `meta` partial.
## Manual / Exploratory Scope
- Визуальное чтение группы без раскрытия: разбивка всегда видна, заголовок не дублирует подпись типа в строках, знак и семантический цвет не единственные носители направления.
- Устойчивость к обновлению: приход `partial` → подтверждённой полноты — итог появляется/исчезает корректно; фоновая догрузка не меняет состав и порядок показанных записей.
- Переходы между режимами «Все счета» ↔ один счёт на одних данных: группировка появляется/исчезает целиком.
- Прокрутка/пагинация журнала в `all`: стабильность позиций групп между страницами.
- Consent-контент: дивидендные группы с датой реестра, оферта без итога — читаемость при отсутствии сумм.
## Automation Candidates
Black-box E2E (Playwright, режим «Все счета»): TC-GRP-001, TC-GRP-002, TC-GRP-005, TC-GRP-006, TC-TOT-001, TC-TOT-002, TC-TOT-003, TC-TOT-004, TC-TOT-005, TC-PAG-001, TC-PAG-002, TC-PAG-003 — стабильные контрактные сценарии групп, итогов и порядка. TC-GRP-003 и TC-GRP-004 — ручные до уточнения Specs. Статус всех: **deferred until QA repo exists**.
## Regression Impact
- Режим одного счёта: журнал, обзор, «Будущие события», «Выплаты» — полное отсутствие группировки (Scenario-блоки требований; ручная регрессия).
- Существующие карточки и строки журнала вне групп: представление выплаты купона/дивиденда, подписи типов, знаки, fallback без metadata — без изменений.
- Состояния загрузки/пустоты/ошибки: первичное ожидание, нейтральная недоступность, сохранение прежнего результата при временном сбое, `partial` и пустая-но-неполная выборка — не деградируют из-за группировки.
- Восстановленный режим `all` в specs журнала и аналитики — поведение не меняется, только smoke доступности (Scenario: Получение журнала с периодом по умолчанию; Scenario: Общий доход требует полноты всех счетов).
- Аналитические итоги дохода и receipt count — только серверные, отсутствие клиентского пересчёта (TC-GRP-006).
## Gaps / Blockers
Нет. Два вопроса из test design закрыты без изменения specs:
- Identity-ключ: specs требуют одинаковую identity Instrument; design D2 определяет её как первый непустой идентификатор, а записи, равенство которых не доказано, не объединяются (TC-GRP-003).
- День: specs задают «день, как на экране»; oracle — показанная в строке дата (TC-GRP-004).
@@ -0,0 +1,64 @@
## MODIFIED Requirements
### Requirement: Realized income analytics is account-scoped and period-bound
Система MUST предоставлять аутентифицированному User аналитику фактически полученного дохода выбранного открытого поддерживаемого Brokerage Account либо существующего режима «Все счета» (`all`) из счетов, доступных через текущий T-Bank connection User. В режиме `all` система MUST проверять доступ каждого входящего счёта и подтверждать полноту каждого исходного периода по всем входящим счетам; отсутствующий вклад счёта MUST NOT заменяться нулём. Применённый период MUST задаваться включительными календарными датами `from` и `to` в Europe/Moscow, MUST NOT начинаться после своего окончания, MUST NOT содержать будущую дату. Система MUST NOT ограничивать максимальную длительность периода. При отсутствии применённого периода система MUST использовать интервал от первого дня текущего месяца до сегодняшнего дня включительно.
Чтение MUST использовать сохранённые операции текущего подключения и локальное подтверждение доступа без синхронного запроса T-Bank. Ответ MUST иметь envelope `{ data, meta }` по `tbank-data-sync`. Полнота MUST определяться для выбранного периода и каждого необходимого периода сравнения отдельно. Запрос MUST приоритетно загружать их пропуски до фоновой догрузки остальной истории; известные правила календарных дат MUST сохраняться.
#### Scenario: Аналитика за период по умолчанию
- **WHEN** аутентифицированный User запрашивает аналитику доступного Brokerage Account без периода
- **THEN** система рассчитывает результат от первого дня текущего месяца по Europe/Moscow до сегодняшнего дня включительно
#### Scenario: Аналитика за допустимый пользовательский период
- **WHEN** User применяет включительный период любой длительности, в том числе более 366 дней, без будущих дат
- **THEN** система рассчитывает аналитику доступного Brokerage Account за все календарные даты этого периода по Europe/Moscow
#### Scenario: Отклонение недопустимого периода
- **WHEN** User запрашивает период с некорректной или будущей датой либо с `from` после `to`
- **THEN** система отклоняет запрос и не возвращает результат для другого или усечённого периода
#### Scenario: Отклонение недоступного счёта
- **WHEN** User запрашивает аналитику Brokerage Account, который не является открытым поддерживаемым счётом, доступным через его текущий T-Bank connection
- **THEN** система возвращает существующий ответ о недоступности счёта и не раскрывает его доход или операции
#### Scenario: Общий доход требует полноты всех счетов
- **WHEN** User запрашивает режим `all` и выбранный период одного доступного счёта ещё не загружен полностью
- **THEN** система не возвращает окончательные общие суммы, сохраняет раздельное состояние покрытия счетов и запрашивает недостающую разрешённую историю
#### Scenario: Открыт ещё не загруженный период
- **WHEN** запрошенный допустимый период ещё не имеет сохранённых операций или подтверждённого полного пустого покрытия
- **THEN** система отвечает `200` со статусом первичной загрузки или частичного покрытия, запрашивает нужные интервалы и не выдаёт нулевой окончательный доход
### Requirement: Income payments remain traceable to exact Ledger Entries
В разделе «Выплаты» страница MUST показывать единый список карточек точных исполненных Ledger Entries применённого периода в исходном серверном порядке entries. Каждая выплата MUST иметь собственную сразу видимую карточку с описанием, датой исполнения, доступной identity Instrument и signed Money в исходной валюте. Купоны и дивиденды MUST использовать то же представление выплаты, что и на экранах журнала, прогноза и графиков инструментов, при этом карточка MUST обозначать эту сумму именно как фактически исполненную, а не прогнозную. В режиме одного Brokerage Account страница MUST NOT добавлять внешние группировочные карточки или заголовки по Instrument либо валюте, subtotals инструмента или количество выплат инструмента. В режиме «Все счета» записи раздела «Выплаты» MUST группироваться по тем же правилам ключа, состава, порядка и итога, что и записи журнала по Requirements «All-accounts ledger groups same-day entries of one instrument», «All-accounts ledger group shows each entry and an exact total» и «All-accounts ledger grouping preserves order and limits» capability `brokerage-account-ledger`. Каждая запись при этом MUST оставаться отдельной сразу видимой карточкой внутри группы, а итог группы MUST NOT показываться, пока полнота периода не подтверждена; заголовки валютных групп и receipt count MUST NOT добавляться. Список MUST сохранять отрицательные корректировки и повторяющиеся operation IDs как отдельные записи и MUST NOT включать записи вне аналитического итога. Backend MUST обеспечивать согласованность entries, instrumentKey, валюты и receipt count при формировании ответа из одного набора записей. Frontend SHALL доверять типизированному contract без второго runtime consistency validator и MUST NOT пересчитывать аналитические денежные итоги. Единственное исключение — итог визуальной группы режима «Все счета»: это точная сумма показанных записей группы по каждой валюте, и он MUST NOT подменять аналитические итоги.
#### Scenario: Каждая выплата показана отдельной карточкой
- **WHEN** в режиме одного Brokerage Account выбранный период содержит несколько включённых Ledger Entries одного или разных Instrument
- **THEN** раздел «Выплаты» сразу показывает отдельную карточку каждой записи в исходном порядке без внешних группировочных блоков, раскрытия, итогов инструмента и receipt count на UI
#### Scenario: Список воспроизводит точный состав дохода
- **WHEN** User просматривает загруженный раздел «Выплаты»
- **THEN** он видит все и только включённые coupon и dividend Ledger Entries применённого периода с датой, описанием, доступной identity и исходной суммой, обозначенной как исполненная выплата
#### Scenario: Отрицательная корректировка и повторяющиеся IDs сохраняются
- **WHEN** доход содержит отрицательную корректировку либо несколько включённых entries с одинаковым operation ID
- **THEN** каждая запись остаётся отдельной карточкой, а корректировка отображается с исходным знаком
#### Scenario: Валюта видна в сумме каждой выплаты
- **WHEN** подходящие в режиме одного Brokerage Account выплаты одного Instrument представлены в нескольких исходных валютах
- **THEN** каждая карточка показывает свою Money с исходной валютой, без отдельного заголовка валютной группы или объединённого мультивалютного итога
#### Scenario: Выплаты одной бумаги на двух счетах в режиме «Все счета»
- **WHEN** в режиме «Все счета» за один день получены купон по облигации на брокерском счёте +200 RUB и купон по той же облигации на ИИС +100 RUB
- **THEN** раздел «Выплаты» показывает их одной группой с итогом +300 RUB, внутри которой видны отдельные карточки каждого счёта, а аналитические итоги страницы не меняются
@@ -0,0 +1,170 @@
## MODIFIED Requirements
### Requirement: User can retrieve an account ledger
Система MUST предоставлять аутентифицированный журнал только для чтения выбранного Brokerage Account либо существующего режима «Все счета» (`all`) в границах текущего User и его T-Bank connection. В режиме `all` доступ MUST проверяться для каждого входящего счёта, операции MUST сохранять принадлежность исходному счёту, а полнота периода MUST учитывать покрытие каждого входящего счёта. Журнал MUST содержать только исполненные операции T-Bank и при отсутствии периода по умолчанию охватывать интервал от начала текущего календарного года в UTC до момента запроса.
Система MUST читать сохранённые операции и локальное подтверждение доступа текущего подключения без синхронного обращения к T-Bank, включая запрос списка счетов. Ответ MUST содержать envelope `{ data, meta }` по `tbank-data-sync`, отражающий полноту запрошенного периода независимо от текущей страницы. Отсутствующие данные MUST возвращаться с `data: null` и состоянием загрузки, а частично сохранённый период MUST обозначаться как `partial`. Только подтверждённо полный пустой период MUST обозначать отсутствие операций.
#### Scenario: Получение журнала с периодом по умолчанию
- **WHEN** аутентифицированный User запрашивает журнал принадлежащего ему Brokerage Account без периода
- **THEN** система возвращает сохранённые исполненные операции в `data` и полноту периода в `meta` за период от начала текущего календарного года в UTC до момента запроса, запрашивая отсутствующие интервалы в фоне
#### Scenario: Отклонение недоступного счёта
- **WHEN** аутентифицированный User запрашивает журнал счёта, для которого локальные данные текущего подключения подтверждают отсутствие доступа либо неподдерживаемое или закрытое состояние
- **THEN** система возвращает существующий ответ о недоступности счёта и не раскрывает операции
#### Scenario: Общий журнал сохраняет принадлежность записей
- **WHEN** аутентифицированный User запрашивает журнал в режиме `all`
- **THEN** система возвращает исполненные операции доступных ему счетов, и каждая запись указывает свой исходный счёт
#### Scenario: Общий журнал содержит неполный счёт
- **WHEN** User запрашивает режим `all` и у одного из разрешённых счетов есть незагруженный интервал периода
- **THEN** система возвращает только разрешённые сохранённые записи с принадлежностью счёту и обозначает общий период неполным, не подменяя отсутствующий вклад пустым счётом
#### Scenario: Пустая загруженная часть не доказывает отсутствие операций
- **WHEN** в сохранённой части периода нет записей, но покрытие периода не завершено
- **THEN** ответ обозначает неполноту и запрошенную загрузку, а интерфейс не сообщает, что операций за период нет
### Requirement: Account overview includes recent ledger events
Обзор счёта MUST показывать пять последних исполненных записей журнала этого
счёта, а в режиме «Все счета» — пять последних записей всех входящих счетов, и действие перехода к его полному журналу. Каждая запись MUST иметь ту же
визуальную идентичность инструмента и семантику денежного движения, что и запись
полного журнала; в группе режима «Все счета» визуальная идентичность, дата и тип
показываются в заголовке группы. Неуспешный или пустой запрос MUST оставаться локальным состоянием
блока и MUST NOT заменять успешно отображённый Portfolio. При временном сбое
обновления блок MUST сохранять доступные записи без технического предупреждения
или ручного повтора. При отсутствии результата он SHALL показывать первичное
ожидание либо нейтральную недоступность, автоматически восстанавливая чтение.
Неподтверждённая пустота MUST NOT представляться отсутствием операций.
#### Scenario: Отображение последних событий
- **WHEN** загружается обзор счёта и его журнал содержит исполненные операции
- **THEN** обзор показывает не более пяти последних записей и ссылку на полный журнал
#### Scenario: Последние записи недоступны
- **WHEN** обзор счёта успешно загружается, но запрос последних записей завершается временной ошибкой
- **THEN** Portfolio остаётся доступным, а блок сохраняет прежние записи либо показывает нейтральную недоступность без технического повтора и восстанавливается автоматически
#### Scenario: Последняя запись не содержит visual metadata инструмента
- **WHEN** одна из последних записей не имеет доступной presentation metadata инструмента
- **THEN** обзор показывает для неё понятный текстовый fallback и сохраняет дату, тип, инструмент и денежное изменение записи доступными для чтения
### Requirement: Full ledger presents compact rows
Страница полного журнала MUST показывать для каждой записи дату, тип операции, инструмент и денежное изменение в исходной валюте в компактной структурированной строке. При доступной presentation metadata инструмента строка MUST показывать его визуальную идентичность; при её отсутствии MUST использовать понятный текстовый fallback. Для купонных и дивидендных операций на странице полного журнала и в личной истории инструмента MUST использоваться то же представление выплаты, что в доходе, прогнозе и графиках инструментов, с обозначением исполненного движения. Для каждой записи денежное изменение в исходной валюте MUST сопровождаться подписью конкретного типа события и знаком: для купонной операции — «Купон», для дивидендной — «Дивиденд», для остальных записей — подпись их категории («Сделка», «Налог», «Комиссия», «Перевод», «Прочее»). Строка MUST NOT повторять подпись типа события во вторичной метадате записи. Знак «+» / «−» и семантический цвет MUST оставаться носителями направления и MUST NOT заменяться только текстом. Страница MUST обозначать записи без движения денег нейтрально и MUST NOT показывать цену или количество. В группе режима «Все счета» дата, тип операции, инструмент и его визуальная идентичность MUST показываться один раз в заголовке группы, а строка каждой записи группы MUST показывать её счёт и денежное изменение с подписью типа и знаком.
#### Scenario: Отображение записи только с инструментом
- **WHEN** полный журнал отображает запись без денежной выплаты
- **THEN** страница определяет её как запись без движения денег
#### Scenario: Отображение поступления
- **WHEN** полный журнал отображает запись с положительной денежной выплатой
- **THEN** строка явно показывает поступление знаком и подписью конкретного типа события («Купон», «Дивиденд» или категории) и его денежное изменение в исходной валюте, не полагаясь только на цвет
#### Scenario: Отображение списания
- **WHEN** полный журнал отображает запись с отрицательной денежной выплатой
- **THEN** строка явно показывает списание знаком и подписью конкретного типа события («Купон», «Дивиденд» или категории) и его денежное изменение в исходной валюте, не полагаясь только на цвет
#### Scenario: Отображение купона
- **WHEN** полный журнал или личная история инструмента отображает исполненную операцию с upstream-типом купона
- **THEN** запись показывает подпись «Купон» и исполненную сумму в том же представлении выплаты, что на других экранах
#### Scenario: Отображение дивиденда
- **WHEN** полный журнал или личная история инструмента отображает исполненную операцию с upstream-типом дивиденда
- **THEN** запись показывает подпись «Дивиденд» и исполненную сумму в том же представлении выплаты, что на других экранах
#### Scenario: Подпись типа не дублируется
- **WHEN** полный журнал отображает запись с типом события и вторичной метадатой
- **THEN** подпись типа события присутствует в строке один раз и не повторяется во вторичной метадате записи
#### Scenario: Отсутствует visual metadata инструмента
- **WHEN** полный журнал отображает запись без доступной presentation metadata инструмента
- **THEN** строка показывает понятный текстовый fallback без подмены или сокрытия доступных данных инструмента
#### Scenario: Запись внутри группы режима «Все счета»
- **WHEN** полный журнал в режиме «Все счета» показывает запись в составе группы
- **THEN** её дата, тип и инструмент читаются из заголовка группы, а строка записи показывает счёт и денежное изменение со знаком и подписью типа
## ADDED Requirements
### Requirement: All-accounts ledger groups same-day entries of one instrument
В режиме «Все счета» полный журнал и последние записи обзора MUST объединять в визуальную группу две и более показанных записи с одинаковыми днём (как на экране), identity Instrument и точным upstream-типом операции, даже если они на одном счёте. Записи без Instrument или без пары MUST показываться как раньше. В режиме одного Brokerage Account группировка MUST NOT применяться.
#### Scenario: Купоны одной облигации на двух счетах
- **WHEN** в режиме «Все счета» журнал показывает за один день купон по облигации на брокерском счёте +200 RUB и купон по той же облигации на ИИС +100 RUB
- **THEN** они показаны одной группой с облигацией, типом «Купон» и итогом +300 RUB, а под заголовком видны строки «брокерский счёт +200 RUB» и «ИИС +100 RUB»
#### Scenario: Две операции одного типа на одном счёте
- **WHEN** в режиме «Все счета» журнал показывает за один день две покупки одной бумаги на одном счёте
- **THEN** они показаны одной группой, где каждая покупка остаётся отдельной строкой со своим денежным изменением
#### Scenario: Разные типы по одной бумаге не объединяются
- **WHEN** в режиме «Все счета» за один день по одной облигации есть купон и налог с этого купона
- **THEN** купоны и налоги показаны раздельно, и ни одна группа не содержит записи разных типов
#### Scenario: Покупка и продажа не объединяются
- **WHEN** в режиме «Все счета» за один день показаны покупка и продажа одной бумаги
- **THEN** они не входят в одну группу
#### Scenario: Разные дни не объединяются
- **WHEN** в режиме «Все счета» купоны по одной облигации на двух счетах датированы на экране разными днями
- **THEN** они показаны отдельными записями без группы
#### Scenario: Запись без Instrument
- **WHEN** в режиме «Все счета» за один день показаны две записи одного типа без identity Instrument
- **THEN** они показаны отдельными записями так же, как без группировки
#### Scenario: Режим одного счёта не меняется
- **WHEN** User открывает журнал или обзор одного Brokerage Account, где есть два купона по одной облигации за один день
- **THEN** записи показаны отдельными строками без группы, как до изменения
### Requirement: All-accounts ledger group shows each entry and an exact total
Заголовок группы журнала MUST показывать дату, Instrument с визуальной идентичностью, подпись типа операции, как в строке журнала, и итог. Под ним MUST всегда быть видна, без раскрытия, отдельная строка каждой записи со счётом и денежным изменением. Итог SHALL быть точной суммой со знаком по каждой исходной валюте без конвертации и округления. Итог MUST NOT показываться, если у записи группы нет денежного изменения (включая нулевое) или полнота периода не подтверждена.
#### Scenario: Итог по нескольким валютам
- **WHEN** группа содержит записи с денежными изменениями в RUB и в USD
- **THEN** заголовок показывает отдельный точный итог в RUB и отдельный в USD, без конвертации
#### Scenario: Итог со знаком
- **WHEN** группа содержит поступление +150.10 RUB и отрицательную корректировку −50.05 RUB
- **THEN** итог группы равен +100.05 RUB, а обе записи видны отдельными строками со своими знаками
#### Scenario: Нулевой итог
- **WHEN** группа содержит +150.10 RUB и −150.10 RUB
- **THEN** заголовок показывает итог 0 RUB без знака направления, а обе записи видны со своими знаками
#### Scenario: Группа с записью без денежного изменения
- **WHEN** у одной из записей группы нет денежного изменения
- **THEN** заголовок группы не показывает итог, а записи остаются видны отдельными строками
#### Scenario: Запись с нулевой суммой скрывает итог
- **WHEN** группа содержит запись +100 RUB и запись с денежным изменением 0 RUB
- **THEN** заголовок группы не показывает итог, а запись с нулём обозначена как запись без движения денег
#### Scenario: Итог скрыт при неполном периоде
- **WHEN** журнал в режиме «Все счета» показывает группу, а период обозначен как `partial`
- **THEN** заголовок группы не показывает итог, а записи группы остаются видны со своими суммами
### Requirement: All-accounts ledger grouping preserves order and limits
Группа журнала MUST занимать позицию своей первой записи в серверном порядке; записи внутри группы и остальные строки MUST сохранять относительный серверный порядок. Группировка MUST NOT менять состав, фильтрацию, пагинацию или лимит обзора, который считается по записям. Группа собирается только из показанных записей; это осознанное исключение: её итог относится только к ним и не сопровождается пометкой о неполноте.
#### Scenario: Позиция группы в списке
- **WHEN** серверный порядок записей A, B, C, где A и C образуют группу, а B не входит ни в одну группу
- **THEN** список показывает группу (A, C) на месте A, затем B
#### Scenario: Группа на границе страницы
- **WHEN** записи одной группы попадают на текущую и на ещё не показанную страницу журнала при подтверждённой полноте периода
- **THEN** группа на текущей странице содержит только её показанные записи, итог равен их сумме, и заголовок не содержит пометки о неполноте
#### Scenario: Группировка в последних записях обзора
- **WHEN** в режиме «Все счета» среди пяти последних записей есть две записи с одинаковым ключом
- **THEN** блок обзора показывает те же пять записей, что и без группировки, две из них одной группой
@@ -0,0 +1,100 @@
## MODIFIED Requirements
### Requirement: Account page presents an upcoming events list
Страница Brokerage Account MUST предоставлять действие перехода на отдельную
страницу «Будущие события». Эта страница MUST показывать все полученные события
компактным списком без фильтров, пагинации и суммарного итога списка, с датой, типом,
инструментом и прогнозной суммой в исходной валюте, если она известна. Прогнозный итог
визуальной группы режима «Все счета» не является суммарным итогом списка. Страница
MUST объяснять, что прогноз основан на позиции счёта, указан до налогов и может
быть неполным из-за доступности данных источника. Это предметное ограничение
прогноза MUST NOT превращаться в отчёт о технической синхронизации.
При временном сбое обновления страница MUST сохранять ранее доступный результат
той же выборки с его исходными `calculatedAt` и окном, без индикации
собственного чтения и ручного повтора. Состояние источника из `meta`, включая
неуспех последней часовой попытки и неполноту, MUST представляться по
`frontend-data-resilience`. При отсутствии результата SHALL показываться
первичное ожидание либо нейтральная недоступность с автоматическим
восстановлением по `frontend-data-resilience`. Подтверждённая потеря доступа
MUST скрывать личные данные независимо от наличия сохранённого результата.
#### Scenario: Просмотр ближайших событий
- **WHEN** User открывает «Будущие события» из обзора Brokerage Account
- **THEN** он видит отсортированный список событий ближайших 30 дней, где offer обозначен без суммы
#### Scenario: Пустое состояние страницы
- **WHEN** запрос будущих событий успешно возвращает полный пустой список
- **THEN** страница сообщает, что на ближайшие 30 дней событий не найдено, и объясняет предметные ограничения покрытия источника
#### Scenario: Ошибка страницы
- **WHEN** запрос будущих событий завершается временной ошибкой
- **THEN** страница сохраняет доступный результат той же выборки либо показывает нейтральную недоступность без ручного повтора, восстанавливая чтение автоматически и не выдавая частичные данные за полный прогноз
#### Scenario: Прогнозный дивиденд с неизвестной суммой
- **WHEN** прогноз содержит дивиденд с известной датой реестра, но без суммы
- **THEN** его карточка обозначает дату как дату реестра и сумму как неизвестную, не показывает нулевую или полученную выплату
#### Scenario: Ошибка обновления при сохранённом прогнозе на странице
- **WHEN** запрос будущих событий возвращает последний полный прогноз вместе с ошибкой его обновления
- **THEN** страница показывает этот прогноз с его исходными `calculatedAt` и окном и отдельно обозначает неуспех обновления источника, не предлагая ручной повтор
#### Scenario: Первая загрузка либо ошибка без сохранённого прогноза
- **WHEN** запрос будущих событий возвращает `data: null`, а `meta` обозначает выполнение первой загрузки либо её неуспех
- **THEN** страница показывает доступное состояние первичного ожидания либо нейтральной недоступности без частичного списка и восстанавливает чтение автоматически
## ADDED Requirements
### Requirement: All-accounts upcoming events group same-day events of one instrument
В режиме «Все счета» страница «Будущие события» и ближайшие события обзора MUST объединять в визуальную группу два и более показанных Future Cash Event с одинаковыми датой и видом даты (как на экране), identity Instrument и типом события, даже если они на одном счёте. События без Instrument или без пары MUST показываться как раньше. В режиме одного Brokerage Account группировка MUST NOT применяться.
#### Scenario: Прогноз купонов одной облигации на двух счетах
- **WHEN** в режиме «Все счета» на одну дату ожидается купон по облигации на брокерском счёте 200 RUB и купон по той же облигации на ИИС 100 RUB
- **THEN** они показаны одной группой с облигацией, типом «Купон» и прогнозным итогом 300 RUB, а под заголовком видны строки «брокерский счёт 200 RUB» и «ИИС 100 RUB»
#### Scenario: Разные типы или даты не объединяются
- **WHEN** в режиме «Все счета» по одной облигации ожидаются купон и погашение на одну дату либо купоны на разные даты
- **THEN** ни одна группа не содержит события разных типов или разных дат
#### Scenario: Событие без Instrument
- **WHEN** в режиме «Все счета» на одну дату показаны два события одного типа без identity Instrument
- **THEN** они показаны отдельными строками без группы
#### Scenario: Режим одного счёта не меняется
- **WHEN** User открывает «Будущие события» одного Brokerage Account
- **THEN** события показаны отдельными строками без группы, как до изменения
### Requirement: All-accounts upcoming event group shows each event and a forecast total
Заголовок группы событий MUST показывать дату (для дивиденда — как дату реестра, если она такая), Instrument, тип и прогнозный итог. Под ним MUST всегда быть видна, без раскрытия, отдельная строка каждого события со счётом и прогнозной суммой (или пометкой о неизвестной сумме). Итог SHALL быть точной суммой со знаком по каждой валюте без конвертации и округления. Итог MUST NOT показываться, если у события группы нет суммы или полнота прогноза не подтверждена.
#### Scenario: Событие с неизвестной суммой в группе
- **WHEN** в режиме «Все счета» группа содержит дивиденд на одном счёте с известной суммой и дивиденд по той же бумаге на ту же дату на другом счёте без суммы
- **THEN** заголовок группы не показывает итог, а строка второго счёта обозначает сумму как неизвестную
#### Scenario: Оферта без сумм
- **WHEN** в режиме «Все счета» на одну дату ожидается оферта по одной облигации на двух счетах
- **THEN** они показаны одной группой без итога, и каждая строка показывает свой счёт
#### Scenario: Прогнозный итог по нескольким валютам
- **WHEN** группа содержит дивиденд 10 USD на одном счёте и дивиденд 200 RUB по той же бумаге на ту же дату на другом счёте
- **THEN** заголовок показывает отдельные прогнозные итоги 10 USD и 200 RUB без конвертации
#### Scenario: Прогнозный итог скрыт при неполном прогнозе
- **WHEN** в режиме «Все счета» прогноз обозначен неполным, и на странице есть группа событий с известными суммами
- **THEN** заголовок группы не показывает итог, а строки событий остаются видны со своими суммами
#### Scenario: Группа дивидендов по дате реестра
- **WHEN** группа содержит дивиденды, датированные датой реестра
- **THEN** заголовок обозначает дату как дату реестра
### Requirement: All-accounts upcoming event grouping preserves order and limits
Группа событий MUST занимать позицию своего первого события в порядке ответа; события внутри группы и остальные строки MUST сохранять относительный порядок ответа. Группировка MUST NOT менять состав, окно, порядок по дате или лимит обзора, который считается по событиям; группы собираются только из показанных событий.
#### Scenario: Группировка в ближайших событиях обзора
- **WHEN** в режиме «Все счета» среди пяти ближайших событий есть два события с одинаковым ключом
- **THEN** блок обзора показывает те же пять событий, что и без группировки, два из них одной группой
#### Scenario: Позиция группы событий
- **WHEN** порядок ответа содержит события A, B, C, где A и C образуют группу, а B не входит ни в одну группу
- **THEN** список показывает группу (A, C) на месте A, затем B
@@ -0,0 +1,73 @@
# Tasks
## 1. Общая механика и ключи
- [x] 1.1 [frontend] Implementation: чистая группировка `groupItems` и точное десятичное сложение по валютам в `shared/lib` с unit-тестами
- **Covers:** `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`, `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`
- **Source:** design.md — D1, D3; Developer Verification Strategy (unit grouping, unit decimal/money)
- **Verification:** `npm run test:run -- src/shared/lib src/pages/account/model`; `npm run typecheck`
- **Depends on:** none
- [x] 1.2 [frontend] Implementation: ключи группы и правило показа итога для операций и событий (день как на экране, identity, точный тип, `dateKind`), unit-тесты key adapters
- **Covers:** `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`, `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`, `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total`
- **Source:** design.md — D2, D3; Components and Responsibilities (`entities/broker-operation/model/display.ts`, `entities/brokerage-event/model/event-key.ts`)
- **Verification:** `npm run test:run -- src/entities/broker-operation src/entities/brokerage-event`
- **Depends on:** 1.1
## 2. Представление групп
- [x] 2.1 [frontend] Implementation: компоненты группы и строки счёта для операций и событий по ux-design (заголовок, вложенный список, скрытый итог, доступные имена), с component-тестами
- **Covers:** `brokerage-account-ledger` — `Full ledger presents compact rows`, `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`, `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total`
- **Source:** design.md — UI Implementation; ux-design.md — Information Architecture, UI States, Accessibility
- **Verification:** `npm run test:run -- src/entities packages/ui`; проверка в браузере на 320 px и при zoom 200%
- **Depends on:** 1.2
- [x] 2.2 [frontend] Implementation: группировка в режиме `all` на страницах журнала и «Будущие события». Полнота берётся из `meta`, путь одного счёта не меняется. Route-тесты, включая pagination через MSW
- **Covers:** `brokerage-account-ledger` — `User can retrieve an account ledger`, `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`, `brokerage-account-upcoming-events` — `Account page presents an upcoming events list`, `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`
- **Source:** design.md — D4, Components and Responsibilities (`pages/account-operations`, `pages/account-events`)
- **Verification:** `npm run test:run -- src/pages/account-operations src/pages/account-events`
- **Depends on:** 2.1
- [x] 2.3 [frontend] Implementation: группировка в превью обзора `/accounts/all` после лимита в 5 записей, с preview-тестами
- **Covers:** `brokerage-account-ledger` — `Account overview includes recent ledger events`, `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`, `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`
- **Source:** design.md — Components and Responsibilities (`pages/account/model/previews.ts`, `RecentOperations.view.tsx`, `BrokerageEventsPreview.tsx`)
- **Verification:** `npm run test:run -- src/pages/account`
- **Depends on:** 2.1
- [x] 2.4 [frontend] Implementation: группировка «Выплат» в режиме `all`. Ключи повторных выплат сохраняются, серверные аналитические итоги не меняются. Route-тесты
- **Covers:** `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries`, `brokerage-account-income-analytics` — `Realized income analytics is account-scoped and period-bound`
- **Source:** design.md — Components and Responsibilities (`pages/account-income/model/display.ts`)
- **Verification:** `npm run test:run -- src/pages/account-income`
- **Depends on:** 2.1
- [x] 2.5 [frontend] Verification: полный набор frontend gates на итоговом diff
- **Covers:** N/A — сводная проверка задач 1.1–2.4
- **Source:** design.md — Developer Verification Strategy
- **Verification:** `npm run verify` (после `nvm use --silent`)
- **Depends on:** 2.2, 2.3, 2.4
## 3. Документация
- [x] 3.1 [docs] Docs: обновить AS-IS страниц операций, будущих событий, дохода и режима «Все счета»
- **Covers:** N/A — docs отражают реализованное поведение
- **Source:** tasks.md — Docs Impact
- **Verification:** semantic review против реализации; локальные проверки docs-репозитория по его AGENTS
- **Depends on:** 2.5
## 4. Приёмка
- [x] 4.1 [frontend] Independent Review: read-only review кода против specs, ux-design и design
- **Covers:** N/A — проверка реализации всех ADDED и MODIFIED Requirements
- **Source:** process-record.md — протокол G4
- **Verification:** отчёт independent-reviewer без открытых blocker и major
- **Depends on:** 2.5
- [x] 4.2 [frontend] QA Verification: системная проверка по qa-analysis и test-cases на локальном стенде
- **Covers:** N/A — проверка всех ADDED и MODIFIED Requirements по Coverage Matrix
- **Source:** qa-analysis.md — Coverage Matrix; test-cases/*.md
- **Verification:** отчёт qa-engineer по каждому TC и Scenario из матрицы
- **Depends on:** 2.5
## Docs Impact
Обновляются AS-IS страницы в `docs` (по design.md — Docs Impact):
- `docs/domains/operations/overview.md`: группы журнала и превью; точный клиентский итог по показанным записям.
- `docs/domains/income/upcoming-events.md`: группы полного списка и превью, `dateKind`, когда показывается прогнозный итог.
- `docs/domains/income/overview.md`: группировка «Выплат» в режиме `all`; визуальный итог группы отдельно от серверной аналитики.
- `docs/domains/portfolio/all-accounts.md`: группировка в общих списках только в пределах страницы, cursor protocol прежний.
`docs/design/overview.md` не меняется: граница между UI foundation и предметным представлением остаётся прежней. ADR не требуется.
@@ -0,0 +1,69 @@
# Test Cases — grouping
## TC-GRP-001 — Интерливинг трёх записей одной группы в серверном порядке
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument` (new combination: порядок внутри группы из 3+ записей с интерливингом чужих записей)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета»; брокерский счёт и ИИС доступны; период подтверждённо полный.
**Test Data:** Серверный порядок записей одного дня: A1 (купон X, Брокерский, +200 RUB), B (перевод, без Instrument), A2 (купон X, ИИС, +100 RUB), C (купон Y, Брокерский, +50 RUB), A3 (купон X, Брокерский, +150 RUB).
**Steps:**
1. Открыть полный журнал в режиме «Все счета» за этот день.
2. Зафиксировать порядок групп и записей.
**Expected Results:** Группа X (A1+A2+A3) стоит на месте A1 — до B; внутри группы порядок A1, A2, A3; затем B, затем C. Относительный порядок B и C сохранён. Итог группы +450 RUB.
**Automation Candidate:** yes (E2E)
## TC-GRP-002 — Две разные группы, записи которых чередуются
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument` / `All-accounts ledger grouping preserves order and limits` (new combination)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** Порядок: X1 (купон X), Y1 (дивиденд Y), X2, Y2, X3. X — облигация, Y — акция; купоны X по 100 RUB (Брокерский, ИИС, Брокерский), дивиденды Y по 50 RUB (ИИС, Брокерский).
**Steps:**
1. Открыть журнал «Все счета» за день.
**Expected Results:** Две группы: группа X на месте X1 с итогом +300 RUB, группа Y на месте Y1 с итогом +100 RUB; порядок групп X → Y; внутри каждой — серверный порядок записей.
**Automation Candidate:** yes (E2E)
## TC-GRP-003 — Одна и та же бумага: figi-only identity против instrumentUid — не смешивать разные идентичности
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument` (new combination: строгость identity-ключа)
**Risk / Priority:** Средний / High
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** В один день: купон облигации Z на счёте 1 (+100 RUB, identity по figi), купон облигации Z на счёте 2 (+100 RUB, identity по instrumentUid без figi); и купон облигации W на двух счетах (+100 RUB и +50 RUB), где обе записи имеют одинаковый identity.
**Steps:**
1. Открыть журнал «Все счета» за день.
**Expected Results:** Облигация W образует группу с итогом +150 RUB. Две записи облигации Z показаны отдельно: равенство identity не доказано, ключ строится по первому непустому идентификатору (design D2), а группировка без доказанного равенства не выполняется.
**Automation Candidate:** yes (E2E со stubbed API, отложено до появления QA repo)
## TC-GRP-004 — Граница дня около полуночи местного времени
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument` (new combination: экранный день в местном времени)
**Risk / Priority:** Средний / Medium
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** Купон X +100 RUB с execution time 23:59:30 Europe/Moscow и купон X +100 RUB с execution time 00:00:30 следующего календарного дня Europe/Moscow.
**Steps:**
1. Открыть журнал «Все счета», охватывающий оба момента.
**Expected Results:** Экранный день определяется так же, как дата, которую строка показывает на этом экране (spec: «как на экране», design D2 — локальная timezone браузера). При timezone браузера Europe/Moscow записи показаны в разных днях и группу не образуют. Oracle — дата, показанная в строке, а не UTC-дата `occurredAt`.
**Automation Candidate:** yes (E2E с фиксированной timezone браузера, отложено до появления QA repo)
## TC-GRP-005 — Прогноз: неизвестная сумма + известные суммы в двух валютах в одной группе
**Verifies:** `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total` (new combination)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета», страница «Будущие события».
**Test Data:** Дивиденд D: событие на Брокерском 10 USD (известно), событие на ИИС 200 RUB (известно), событие на третьем счёте без суммы; все — одна дата реестра, один точный тип.
**Steps:**
1. Открыть «Будущие события» в режиме «Все счета».
**Expected Results:** Одна группа из трёх событий; строка без суммы показывает «неизвестно»; итог группы скрыт, потому что у одного события сумма неизвестна (прогноз полный, но не полностью известен по суммам); известные 10 USD и 200 RUB показаны отдельными строками, без конвертации и без общего итога. Дата в заголовке обозначена как дата реестра.
**Automation Candidate:** yes (E2E)
## TC-GRP-006 — «Выплаты» в `all`: отрицательная корректировка и дубли operation IDs внутри одной группы, аналитические итоги не меняются
**Verifies:** `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries` / Scenario: Отрицательная корректировка и повторяющиеся IDs сохраняются (new combination: то же внутри группы `all`)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета», раздел «Выплаты», период подтверждённо полный.
**Test Data:** За один день по облигации X: купон +200 RUB (Брокерский, opId=77), купон +100 RUB (ИИС, opId=77 — дубликат), корректировка −50 RUB (Брокерский, тот же тип). Аналитические итоги страницы зафиксированы до.
**Steps:**
1. Открыть «Выплаты» в режиме «Все счета» за период.
2. Сравнить аналитические итоги с зафиксированными.
**Expected Results:** Одна группа из трёх карточек; обе записи opId=77 — отдельные карточки; корректировка со знаком «−»; итог группы +250 RUB; валютные заголовки групп и receipt count отсутствуют; аналитические итоги страницы не пересчитаны и не изменились.
**Automation Candidate:** yes (E2E)
@@ -0,0 +1,38 @@
# Test Cases — pagination
## TC-PAG-001 — Группа, разрезанная границей страницы журнала: конкретное содержимое страниц и переходы
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits` / Scenario: Группа на границе страницы (new combination: конкретное содержимое страниц, next/prev)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета»; полный период; тестовые данные строятся вокруг фактического размера страницы N, который возвращает сервер (фиксируется в момент прогона).
**Test Data:** Серверный порядок: p1..p5 (прочие записи), X1 (купон X, +100 RUB), X2 (купон X, +150 RUB), X3 (купон X, +50 RUB), p9..p12. Размер страницы N — как у сервера; данные готовятся так, чтобы X1 был последней записью страницы 1, а X2, X3 — началом страницы 2.
**Steps:**
1. Открыть страницу 1 журнала; зафиксировать состав.
2. Перейти на страницу 2.
3. Вернуться на страницу 1.
**Expected Results:** Страница 1 заканчивается записью X1 как обычной строкой: группы нет (для группы нужно ≥2 показанных записей), итог не показывается. Страница 2 начинается группой X2+X3 (итог +200 RUB, разбивка X2/X3), затем p9…. Возврат на страницу 1 воспроизводит тот же состав. Серверный относительный порядок нигде не нарушен; записи одной группы не «стягиваются» через границу страницы.
**Automation Candidate:** yes (E2E)
## TC-PAG-002 — Обзор «Все счета»: пять записей, шестая подходящая существует, но не показана
**Verifies:** `brokerage-account-ledger` — `Account overview includes recent ledger events` / Scenario: Отображение последних событий (new combination: группа на границе лимита 5)
**Risk / Priority:** Высокий / High
**Preconditions:** Обзор «Все счета».
**Test Data:** Последние 6 записей в серверном порядке (свежая → старая): X1 (купон X, ИИС, +100 RUB), p2, p3, p4, p5, X0 (купон X, Брокерский, +200 RUB — шестая, за пределами выборки). X1 и X0 приходятся на один и тот же экранный день и один точный тип — по ключу они образовали бы группу, но X0 не входит в выборку обзора.
**Steps:**
1. Открыть обзор «Все счета».
2. Перейти в полный журнал.
**Expected Results:** В обзоре ровно 5 записей; X1 не образует группу (второй записи с тем же ключом в выборке нет) и показан отдельной строкой; X0 в обзоре отсутствует. Полный журнал показывает группу X0+X1 с итогом +300 RUB на месте своей первой записи.
**Automation Candidate:** yes (E2E)
## TC-PAG-003 — Обзор «Все счета»: лимит 5 считается по записям, группа занимает одну визуальную позицию
**Verifies:** `brokerage-account-ledger` — `Account overview includes recent ledger events` / Scenario: Группировка в последних записях обзора (new combination: счётчик лимита)
**Risk / Priority:** Средний / Medium
**Preconditions:** Обзор «Все счета».
**Test Data:** Последние записи (свежая → старая): p1, X1 (купон X, +100 RUB), X0 (купон X, +200 RUB, тот же экранный день и точный тип), p4, p5, p6.
**Steps:**
1. Открыть обзор «Все счета».
2. Посчитать визуальные позиции и проверить отсутствие p6.
**Expected Results:** Лимит 5 считается по записям: показаны записи p1, X1, X0, p4, p5 — это 4 визуальные позиции: p1, группа X (на месте X1, итог +300 RUB, разбивка X1/X0), p4, p5. Запись p6 не показана: она шестая по счётчику записей, свободной позиции от лимита нет.
**Automation Candidate:** yes (E2E)
@@ -0,0 +1,58 @@
# Test Cases — totals
## TC-TOT-001 — Мультивалютная группа со смешанными знаками и большой разрядностью: точный десятичный оракул
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total` (new combination: calculated oracle)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** Один день, облигация X, точный тип «купон»: +0.1 RUB (Брокерский), +0.2 RUB (ИИС), −1500000.45 RUB (Брокерский, корректировка того же типа), +200 RUB (ИИС); плюс в той же группе день выплат USD: +0.01 USD и −0.02 USD.
**Steps:**
1. Открыть журнал «Все счета» за день.
2. Прочитать итог группы по каждой валюте.
**Expected Results:** Итог RUB = −1499800.15 (точная десятичная сумма: 0.1+0.2−1500000.45+200), без плавающей точки артефактов (не −1499800.1499999); итог USD = −0.01; два отдельных итога, без конвертации и без общего итога; каждая строка показывает свою сумму со знаком.
**Automation Candidate:** yes (E2E)
## TC-TOT-002 — Разное число знаков после запятой у записей группы
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total` (new combination)
**Risk / Priority:** Средний / High
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** Группа купонов X: +100.5 RUB, +49.50 RUB, +0.005 RUB (если сервер отдаёт такую точность).
**Steps:**
1. Открыть журнал, прочитать итог.
**Expected Results:** Итог = 150.005 RUB ровно, без округления до 2 знаков и без потери младшего разряда; строки показывают суммы как отданы сервером.
**Automation Candidate:** yes (E2E)
## TC-TOT-003 — Нулевой итог и запись без денежного изменения в одной группе
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total` / Scenario: Нулевой итог; Scenario: Группа с записью без денежного изменения (new combination: обе записи в одной группе)
**Risk / Priority:** Средний / Medium
**Preconditions:** Режим «Все счета», полный период.
**Test Data:** Группа X: +150.10 RUB, −150.10 RUB, запись без денежного изменения (например, только количество бумаг).
**Steps:**
1. Открыть журнал, прочитать группу.
**Expected Results:** Итог группы скрыт; запись без изменения обозначена как «без движения», а не как 0 RUB; строки остаются видимыми.
**Automation Candidate:** yes (E2E)
## TC-TOT-004 — `partial` → подтверждённая полнота: итог появляется
**Verifies:** `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total` / Scenario: Итог скрыт при неполном периоде (new combination: смена состояния)
**Risk / Priority:** Высокий / High
**Preconditions:** Режим «Все счета»; заглушка API (MSW/Playwright route) с управляемым `meta`.
**Test Data:** Группа X: +200 RUB и +100 RUB. Первый ответ — `meta` с `partial` для одного из счетов; второй ответ — подтверждённая полнота.
**Steps:**
1. Открыть журнал с partial-ответом; зафиксировать группу.
2. Подтвердить полноту (повторный запрос/фоновая догрузка с полной `meta`).
**Expected Results:** При `partial`: строки видны, итог скрыт. После полноты: появляется точный итог +300 RUB; состав и порядок строк не изменились.
**Automation Candidate:** yes (E2E)
## TC-TOT-005 — Прогнозный итог при частично неизвестных суммах скрыт, при полной известности — показан по валютам
**Verifies:** `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total` (new combination)
**Risk / Priority:** Средний / High
**Preconditions:** Режим «Все счета», «Будущие события».
**Test Data:** Два соседних дня: день 1 — купоны X на двух счетах по 100 RUB (обе суммы известны); день 2 — купоны X на двух счетах, у одной суммы нет.
**Steps:**
1. Открыть «Будущие события».
**Expected Results:** Группа дня 1: итог +200 RUB. Группа дня 2: итог скрыт, строка без суммы обозначена как неизвестная. Итоги списком не сводятся.
**Automation Candidate:** yes (E2E)
@@ -0,0 +1,211 @@
# UX Design
Задача пользователя: в режиме «Все счета» (`/accounts/all/...`) быстро увидеть, что
несколько записей — это один и тот же факт (один день, одна бумага, один тип), понять
общую сумму и при этом сразу видеть вклад каждого счёта. Нормативное поведение
(ключ группы, состав, порядок, правила итога) задано в `proposal.md` и трёх spec;
этот документ его не повторяет и не меняет, а только определяет представление.
Допущение: design system — Mantine + `@wealthledger/ui` по `docs/design/overview.md`
(семантические роли, направление несут знак и текст, не только цвет).
## Requirements Covered
- `brokerage-account-ledger` — `Full ledger presents compact rows` (MODIFIED, группа в полном журнале)
- `brokerage-account-ledger` — `Account overview includes recent ledger events` (MODIFIED, группа в обзоре)
- `brokerage-account-ledger` — `User can retrieve an account ledger` (MODIFIED, режим `all`, `partial`)
- `brokerage-account-ledger` — `All-accounts ledger groups same-day entries of one instrument`
- `brokerage-account-ledger` — `All-accounts ledger group shows each entry and an exact total`
- `brokerage-account-ledger` — `All-accounts ledger grouping preserves order and limits`
- `brokerage-account-upcoming-events` — `Account page presents an upcoming events list` (MODIFIED, прогнозный итог группы)
- `brokerage-account-upcoming-events` — `All-accounts upcoming events group same-day events of one instrument`
- `brokerage-account-upcoming-events` — `All-accounts upcoming event group shows each event and a forecast total`
- `brokerage-account-upcoming-events` — `All-accounts upcoming event grouping preserves order and limits`
- `brokerage-account-income-analytics` — `Realized income analytics is account-scoped and period-bound` (MODIFIED, режим `all`)
- `brokerage-account-income-analytics` — `Income payments remain traceable to exact Ledger Entries` (MODIFIED, «Выплаты» в режиме `all`)
## User Flows
Цель пользователя — «что и сколько пришло/придёт по бумаге X и с каких счетов».
1. **Просмотр списка.** Пользователь открывает любой из 4 списков в режиме «Все счета» →
видит заголовок группы (дата, бумага, тип, итог) → читает строки счетов под ним,
раскрывать ничего не нужно.
2. **Переход к счёту.** Из строки группы (журнал, выплаты) пользователь открывает
`/accounts/{id}` по имени счёта; возврат — кнопкой «Назад» браузера, список и позиция
восстанавливаются как сейчас.
3. **Листание журнала.** «Назад/Вперёд» меняют страницу; группа собирается заново из показанных
записей страницы. Пользователь не получает пометок о неполноте на границе страницы.
4. **Обзор.** На `/accounts/all` в блоках «Ближайшие события» и «Последние операции» группа
занимает место первой записи; блок по-прежнему содержит не более 5 записей/событий, как сейчас.
5. **Одиночная запись / одиночный счёт.** Запись без пары, без Instrument или любой список в
режиме одного счёта выглядят как сейчас; пользовательский путь не меняется.
Ветвление по итогу: итог известен → показан в заголовке; итог скрыт (см. UI States) →
пользователь складывает строки сам или смотрит на состояние страницы (`partial`-Alert).
## Information Architecture
Группа — один визуальный блок (рамка `Paper withBorder`) внутри существующего списка, на
месте первой записи. Состав блока: заголовок + вложенный список строк счетов. Заголовок
повторяет композицию текущей карточки (иконка/логотип | название и метаданные | значение),
строка счёта — компактнее, без логотипа.
Лейблы заголовка: заголовок = «`<тип>` · `<инструмент>`» (как в карточках событий/выплат),
метаданные = дата (+ «дата реестра» для дивиденда по реестру), значение = итог,
подпись значения = подпись типа (в событиях — «Прогноз»).
### Журнал (`/accounts/all/operations`, обзор «Последние операции»)
```
┌──────────────────────────────────────────────────────────┐
│ [logo] Купон · X +300 RUB │ заголовок
│ 12 мая 2026 Купон │
│ ──────────────────────────────────────────────────────── │
│ Брокерский +200 RUB Купон │ строка
│ ИИС +100 RUB Купон │ строка
└──────────────────────────────────────────────────────────┘
```
Строка: имя счёта слева; справа денежное изменение со знаком и подпись типа
(по `Full ledger presents compact rows`). Количество и цена в строках группы не показываются (Presentation Decisions, п. 3).
Имя счёта — обычный текст во всех списках, как в текущей метадате строки (`withDetails` в этих списках не используется).
### События (`/accounts/all/events`, обзор «Ближайшие события»)
```
┌──────────────────────────────────────────────────────────┐
│ [logo] Дивиденд · X 10 USD │
│ 12 мая 2026 · дата реестра 200 RUB │ итоги по валютам
│ Прогноз │
│ ──────────────────────────────────────────────────────── │
│ Брокерский 10 USD │
│ ИИС Сумма неизвестна│
└──────────────────────────────────────────────────────────┘
```
Пример выше показывает два разных состояния сразу; на практике итог при строке
«Сумма неизвестна» скрыт (см. UI States). Строки счёта в событиях — только текст (ссылки
на счёт в текущей карточке события нет).
### Выплаты (`/accounts/all/income`)
```
┌──────────────────────────────────────────────────────────┐
│ [logo] Купон · X +300 RUB │
│ Исполнено 12 мая 2026 Купон │
│ ──────────────────────────────────────────────────────── │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Брокерский +200 RUB │ │ отдельная карточка
│ └──────────────────────────────────────────────────────┘ │ внутри группы
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ИИС +100 RUB │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
В «Выплатах» spec требует «отдельную сразу видимую карточку» записи внутри группы:
вложенные карточки без логотипа и с границей, но визуально легче внешней. Подпись «Исполнено»
сохраняет различие факта и прогноза.
Размещение в 4 списках: страница журнала и «Выплаты» — вертикальный стек с зазором `sm`;
страница событий — тот же стек; блоки обзора — тот же компонент группы без дополнительной
рамки блока. Позиция — по правилам порядка из spec (группа на месте первой записи).
## UI States
| Состояние | Представление |
| --- | --- |
| Одиночная запись | Как сейчас: текущая карточка, без группы и вложенного списка |
| Группа с итогом | Заголовок с итогом `+300 RUB` (тон по знаку), строки счетов |
| Мультивалютный итог | В заголовке столбец значений: по строке на валюту, `+150 RUB` над `+10 USD`; порядок — по первому появлению валюты в записях группы (Presentation Decisions, п. 2); без конвертации и общей суммы |
| Итог скрыт | Значение в заголовке отсутствует; строки счетов видны. Причина: запись без суммы / событие с неизвестной суммой / период `partial`. Для экранных дикторов — скрытый текст «Итог недоступен» (без различения причин) |
| Нулевой итог | `0 RUB` без знака, нейтральный тон; строки с их знаками |
| Offer без сумм | Группа без итога; строки: имя счёта + «Сумма не определена» (существующий текст `events.noAmount`) |
| Прогноз | Итог и суммы строк в событиях несут подпись «Прогноз» (существующий `payout.forecast`); тон нейтральный, как у текущей карточки прогноза |
| Строка без денег (ledger) | Нейтральный текст `payout.noMoney`/«без движения денег» как в одиночной записи; итог группы скрыт |
| Обзор, 5 записей | Не более 5 записей/событий, как сейчас; группа может занимать часть из них, например 2 из 5; кнопка «Все …» ведёт на полную страницу |
| Граница страницы журнала | Группа из показанных записей, без пометки о неполноте и без «ещё N на другой странице» |
| Loading / empty / error / partial-Alert | Не меняются: `LoadingSkeleton`, `StateView`, `Alert` и `SourceSyncStatus` из существующих страниц; группы строятся только для загруженных данных |
Знак: «+» и «−» всегда в тексте; цвет (positive/negative/neutral) — дополнение.
## Visual Package
Решение: **reuse + один новый тонкий контейнер**. Изображения (PNG/SVG) не создаются:
изменение — повторная композиция существующих карточек, а структура и тексты описаны
текстовыми макетами выше; отдельный макет дал бы расхождение с живыми компонентами и
токенами Mantine.
Переиспользуется:
- `EntryCard` (`@wealthledger/ui`) — заголовок группы: логотип/инициал, `title`, `metadata`
(дата), `value` (итог), `valueLabel` (тип/«Прогноз»), `tone`. Отличие: в `value` нужно
выводить несколько значений для нескольких валют (сейчас одна строка) и допустить
отсутствие значения.
- `LedgerEntryRow` / `PayoutCard` (executed) — единичные записи и карточки внутри группы
выплат; в группе опускаются логотип, дата и название инструмента (они в заголовке).
- `BrokerageEventCard` / `PayoutCard` (forecast) — единичные события; тексты
`events.type.*`, `events.noAmount`, `payout.forecast`.
- Форматирование: `formatMoney`, `formatDate`, `instrumentLogoUrl`; тексты `ledger.noInstrument`,
`payout.noMoney`.
- Токены: `--wl-color-border`, `--wl-color-subtle`, `--wl-color-surface`, `Paper withBorder`.
Новое (предложение, с обоснованием «нужно показать иерархию заголовок → строки»):
- Контейнер группы (рамка, заголовок, вложенный список) и компактная строка счёта
(имя счёта слева, сумма справа, без `Avatar`). `EntryCard` всегда рисует `Avatar`, поэтому
для строки счёта нужна его облегчённая форма либо отдельная разметка; выбор — за реализацией.
Отступ строк слева — под текстовым блоком заголовка (≈ 40 px аватар + `sm`), либо без
отступа на узкой ширине.
## Responsive
- Базовый макет — mobile-first: заголовок — сетка из трёх колонок как `EntryCard`
(`auto | 1fr | auto`).
- **≥ 768 px**: заголовок в одну строку; строки счетов — имя слева, сумма и подпись справа.
- **< 576 px (узкий мобильный)**: колонка значения заголовка переносится под название
(название и метаданные — вверху, итог — следующей строкой справа/слева), мультивалютный итог
— столбцом; название инструмента и имя счёта переносятся (`wrap`, без `lineClamp`),
отступ строк слева убирается. Строка счёта: имя счёта сверху, сумма с подписью типа ниже, если
вместе не помещаются; длинное имя счёта не обрезается.
- Никакого горизонтального скролла; минимальная ширина — 320 px.
- Ввод: touch и мышь; в группе нет новых целей нажатия.
## Accessibility
Ориентир — WCAG 2.2 AA.
- **Семантика списка.** Внешний список — `<ul>`; каждая одиночная запись и каждая группа — `<li>`.
Внутри группы-`<li>` заголовок (обычный блок, не `<h*>`, чтобы не засорять навигацию по
заголовкам страницы) и вложенный `<ul>` со строками-`<li>`. Группы в обзоре — внутри уже
существующих секций с их заголовками.
- **Доступное имя группы.** Вложенный `<ul>` получает `aria-label` вида: «Купон, X, 12 мая 2026,
итог +300 RUB» (дата, инструмент, тип, итог, мультивалютный итог — через запятую). При скрытом
итоге — «… итог недоступен». Для знаков в озвучке: «плюс 300 рублей», «минус …» — через
`aria-label` значения либо скрытый текст; сам знак в тексте остаётся.
- **Итог скрыт.** Визуально значения нет; в DOM есть `visually-hidden` текст «Итог недоступен» и
он включён в имя группы. Причина скрытия `partial` уже объявляется существующим `Alert role="status"`.
- **Фокус.** Заголовок и строки группы не фокусируемы: новых интерактивных элементов нет. Если одиночная карточка содержит ссылку, она остаётся как есть, а порядок Tab совпадает с порядком DOM.
- **Не только цвет.** Направление передаёт знак «+»/«−» (минус как «−»), подпись типа и слова
«Прогноз»/«Исполнено»; цвет только усиливает. Контраст текста ≥ 4.5:1 (dimmed `gray.6` на `subtle`
проверить), границы групп ≥ 3:1 не требуются — это декор, смысл не теряется без границы.
- **Динамика.** Смена страницы журнала не перемещает фокус неожиданно; пагинация — как сейчас.
- **Масштаб.** Текст масштабируется до 200 % без потери информации (перенос строк).
## Presentation Decisions
Решения, которые относятся только к представлению и не меняют поведение по specs:
1. **Видимая причина скрытого итога.** Видимого маркера нет. Скринридер получает текст «Итог недоступен». Строки с суммами остаются видны, так что отсутствие итога не выглядит пустотой.
2. **Несколько валют в заголовке.** Каждая валюта на отдельной строке, в порядке первого появления в записях группы.
3. **Количество и цена в строках сделок.** Не показываются, как и в несгруппированном журнале. Specs требуют только счёт и денежное изменение.
4. **Ссылка на счёт.** Сохраняется текущее поведение: имя счёта — текст во всех четырёх списках.
5. **Подпись «Исполнено» / «Прогноз».** Ставится у итога в заголовке. В строках событий подпись остаётся у суммы, как в одиночной карточке.
6. **Дата реестра.** Применяется только к группам прогнозных событий. Исполненные выплаты датируются датой исполнения.
## Findings → Specs
Нет. Два пробела, найденных при проектировании, закрыты решением Change Owner и внесены в specs, G2 инвалидирован:
- прогнозный итог скрывается, если полнота прогноза не подтверждена;
- запись с нулевой суммой считается записью без денежного изменения и скрывает итог.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd-lab
created: 2026-10-30
@@ -0,0 +1,145 @@
# Маппинг данных
## Источник и цель
Документ не выбирает внутренний способ загрузки и хранения. Обозначения **PC**, **PVΔ** и правило нумерации строк — в [system-analysis.md](system-analysis.md). Requirements: `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-concentration/spec.md` (28) и `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-view/spec.md` (4). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). Исходы получения (когда поле заменяется, сохраняется или становится `null`, когда отсчёт 24 часов начинается заново) определяет таблица PVΔ:156; здесь она не пересказывается.
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
| --- | --- | --- | --- | --- |
| M1: сектор | `sector` Share / Bond / Etf (T-Bank) | `positions[].sector` (backend) | T-Bank → хранимое значение → HTTP Portfolio | Часовой обход по условию PVΔ:141 |
| M2: эмитент | `asset_uid` инструмента → `AssetFull.brand.uid` / `name` (T-Bank) | `positions[].issuer` (backend) | T-Bank → хранимое значение → HTTP Portfolio | То же |
| M3: проекция | Полный ответ Portfolio (backend — владелец API) | Разрезы, суммы, доли, подписи, имя эмитента (frontend) | HTTP Portfolio → вычисляемая проекция | Получение ответа, смена разреза или валюты; обращений к T-Bank нет |
Финансовый источник не меняется: количество и цена из GetPortfolio, деньги из GetPositions; sector и issuer деньги не пересчитывают (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`; `backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:33-55,63-98`).
### Источники-кандидаты в T-Bank
Выбор между точечным запросом и пригодными ранее сохранёнными данными — design: Specs его не определяют. Сопоставление идёт по instrument UID, затем по FIGI (PVΔ:3); эмитент по названию, тикеру или ISIN не определяется.
| Потребность | Кандидат и поля ответа | Подтверждение / ограничения |
| --- | --- | --- |
| STOCK: sector, asset UID | `ShareBy(InstrumentRequest)` → `Share.sector`, `Share.asset_uid` | RPC `backend/src/integrations/tbank/proto/contracts/instruments.proto:65`; поля `:760,:776` |
| BOND: sector, asset UID | `BondBy(InstrumentRequest)` → `Bond.sector`, `Bond.asset_uid` | RPC `instruments.proto:24`; поля `:525,:543` |
| FUND: sector, asset UID | `EtfBy(InstrumentRequest)` → `Etf.sector`, `Etf.asset_uid` | RPC `instruments.proto:42`; поля `:644,:657` |
| Общая идентичность | `GetInstrumentBy` → `Instrument.uid`, `figi`, `asset_uid` | `asset_uid` есть (`instruments.proto:971`); поля `sector` в `Instrument` нет, поэтому им одним сектор не получить |
| Бренд актива | `GetAssetBy(AssetRequest{id: asset_uid})` → `AssetFull.brand.uid`, `brand.name` | `instruments.proto:1030-1036,1051-1068,1220-1229`; `asset_uid` — ключ запроса, не значение `issuer.id` |
| Список активов | `GetAssets` → `Asset[]` | У `Asset` нет `brand` (`instruments.proto:1075-1080`); для `issuer` не подходит |
### Что уже есть в коде
- GetInstrumentBy по UID и FIGI даёт общую metadata. Его `brand` — `BrandData` (`logo_name`, цвета), не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/tbank.client.ts:293-305,493-509`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Эмитента из `logoName` или названия инструмента получить нельзя.
- Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; типы `TbankShare`/`TbankBond` их содержат (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:341-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23,48-61`). Это отдельный источник, и пользователь не должен сначала открывать карточку инструмента.
- `GetAssetBy` уже вызывается ради `description`; `brand` из ответа не передаётся (`backend/src/integrations/tbank/tbank.client.ts:420-429`; `backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:431-437`). `GetAssets` в TypeScript интеграции не используется.
- **Расхождения AS-IS с нормами, которые закрывает реализация** (это требования, а не выбор):
- запись общей metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`) — норма PVΔ:141 требует срок от последнего успешного получения;
- клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`) — по PVΔ:156 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».
## Маппинг полей
В колонке «При ошибке» исход определяется таблицей PVΔ:156; «не найдено» и «значения нет» — один исход. Колонка не повторяет решений таблицы.
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `position.sector` | `Share.sector` / `Bond.sector` / `Etf.sector` по типу STOCK / BOND / FUND | Обрезать пробелы по краям; пусто или только пробелы → «значения нет»; остальное как получено, без lowercase, перевода и классификации | Код, без валюты | string или null | `null` | Строка; ответ принадлежит запрошенному инструменту | По PVΔ:156; `issuer` и финансы не затрагиваются |
| `position.sector` у остальных типов | — | Всегда `null`, получение не нужно | — | `null` | `null` | Тип позиции из нормализации | — |
| `position.issuer.id` | `AssetFull.brand.uid` | Обрезать пробелы; перенос без перекодирования | Непрозрачный идентификатор | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки; `AssetFull.uid` совпадает с запрошенным `asset_uid` | По PVΔ:156 |
| `position.issuer.name` | `AssetFull.brand.name` | Обрезать пробелы; не `brand.company`, не `asset.name` | Текст | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки | По PVΔ:156 |
| `position.issuer` | Предыдущие две строки одного ответа | `{id, name}` только если оба пригодны, иначе «значения нет»; STOCK / BOND / FUND, включая FUND; прочие типы `null` | Объект | object или null | `null` | Частичный объект запрещён | По PVΔ:156, объект целиком |
| Время последнего успешного получения | Исход получения | Меняется и не меняется по таблице PVΔ:156; читается условием PVΔ:141; ведётся отдельно для `sector` и `issuer` (исходы двух запросов могут различаться) | Момент времени | Нет значения до первого успешного получения | Нет | — | — |
| Новые ключи старого Snapshot | Отсутствуют | Отдавать как `null` | — | как в контракте | `null` | Отличать от отсутствия полного Portfolio | Snapshot не отбрасывать |
| Название сектора | `position.sector` | Точное совпадение с PC:374 → подпись; иное непустое значение → как получено; `null` → «Сектор не указан» | Текст отображения | Название непустое | Группа «Сектор не указан» | Ключ группировки — код, не подпись | Неизвестный код не объединять с `null` |
| Название эмитента | `issuer.name` позиции с наибольшим `currentValue` среди учитываемых позиций одного `issuer.id` в выбранной валюте | Группировать по `issuer.id`; при равных наибольших значениях — меньшее название по алфавитному порядку PC:274; `null` → «Эмитент не указан» | Текст отображения | Название непустое | Группа «Эмитент не указан» | Одинаковое `name` при разных `id` не объединяет | Нет |
| Сумма элемента / разреза | `currentValue`, `type`, `sector`, `issuer.id` | Отбор → сумма по ключу; без округления | Десятичная строка Money, одна валюта | Для учтённых элементов | Пустое состояние | Не смешивать валюты | Непригодные sector/issuer меняют группу, не сумму |
| Доля элемента, метрика топ-5 | Сумма элемента, сумма разреза | Точное отношение; метрика — сумма точных долей первых пяти; округление только при показе | Доля → % | Нет при пустом разрезе | Пустое состояние | Знаменатель > 0 | Не делить на ноль, не выдавать NaN |
**Нормализация строк.** Источник правил — PVΔ:3: сектор «как определяет T-Bank, без перекодирования», края обрезаются, значение из одних пробелов отсутствует. Регистр не меняется: `energy` и `Energy` — разные коды (PC:364 запрещает объединять разные `sector`). Для `issuer.id` Specs требуют непустоту после обрезки; обрезка возвращаемого `id` следует решению 3081 «Пробелы обрезать» и нужна, чтобы идентификатор не расщеплял группу.
**Что считается «значения нет».** Таблица PVΔ:156 использует понятие, но не перечисляет признаки; mapping задаёт их для полей этого документа: нет поля `sector`, `asset_uid`, `brand` в ответе; пустая строка либо только пробелы после обрезки; пара `brand.uid` / `brand.name` непригодна. Ответ о другом инструменте или активе распознаётся несовпадением UID / FIGI / `asset_uid` с запросом (проверка UID в AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:141-160`).
**Подписи секторов.** Нормативный справочник — PC:374: consumer, energy, financial, health_care, industrials, it, materials, real_estate, telecom, utilities, government. Это только отображение; `Brand.sector` как источник не используется. `other` остаётся `other` (PC:364).
### Применимость по типу позиции
| Portfolio type | `sector` | `issuer` | Участие в разрезах |
| --- | --- | --- | --- |
| STOCK | `Share.sector` | `AssetFull.brand` или `null` | Акции, Секторы, Эмитенты |
| BOND | `Bond.sector` | `AssetFull.brand` или `null` | Облигации, Секторы, Эмитенты |
| FUND | `Etf.sector` | `AssetFull.brand` или `null`; **заполняется** (PVΔ:3, решение 3081) | Только Секторы; разрез «Эмитенты» фонд отбрасывает (PC:69) |
| CURRENCY, FUTURE, OPTION, STRUCTURED_NOTE, DIGITAL_FINANCIAL_ASSET, OTHER | Всегда `null` | Всегда `null` | Ни в одном из четырёх разрезов |
Типы share → STOCK, bond → BOND, etf → FUND нормализуются mapper'ом (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31`); sector и issuer тип не меняют.
## Инварианты
| ID | Правило | Защищаемое Requirement |
| --- | --- | --- |
| I1 | Обогащение не удаляет, не объединяет и не переставляет позиции; одинаковый FIGI не означает дубликат | PVΔ:3 |
| I2 | quantity, `currentValue`, `averagePurchasePrice`, `absoluteReturn`, `cashBalances` и агрегаты не зависят от sector/issuer; `null` не делает стоимость неизвестной. Исключение — отказ token (PVΔ:104) | PVΔ:3; PC:484 |
| I3 | В одном разрезе позиция участвует не более одного раза; фонд попадает в «Секторы», но не в «Акции», «Облигации», «Эмитенты» | PC:69 |
| I4 | Учитываются только позиции с `currentValue` > 0 в выбранной валюте; отрицательные и неоценённые исключаются, не берутся по модулю | PC:129 |
| I5 | Знаменатель = сумма учитываемых позиций разреза в выбранной валюте = сумма элементов; Cash Balance и `totalsByCurrency` не участвуют; `null`-группа входит в сумму | PC:289, PC:349 |
| I6 | «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными, не подменяются кодом T-Bank и не сливаются с реальным значением | PC:349 |
| I7 | До 6 элементов — все строки; от 7 — пять и «Прочее» (сумма остальных, число остальных); метрика «доля топ-5» — только при ≥ 6 элементах | PC:229, PC:324 |
| I8 | Порядок: сумма по убыванию; при равенстве — название (цифры, латиница, кириллица), затем порядок первой позиции. Название эмитента — по позиции с наибольшим `currentValue`, при равных — меньшее по алфавиту; сумму и группировку оно не меняет | PC:274, PC:114 |
| I9 | Точные доли дают ровно 100 %; «Прочее» = сумма долей остальных; округление только при показе; сумма показанных долей может не быть 100 % | PC:289, PC:304 |
| I10 | Список, диаграмма и центр используют одни суммы; раскрытие «Прочее» не меняет диаграмму; в центре — сумма разреза и, при метрике, «Топ-5 · N %» | PC:254, PC:402, PC:415 |
| I11 | Валюты вкладки — ненулевые оценки STOCK/BOND/FUND (включая отрицательные), по ISO; выбора нет при ≤ 1 валюте | PC:169, PC:184 |
| I12 | N сноски = позиции разреза с `currentValue` `null` в любой валюте + отрицательные в выбранной; нулевые и отрицательные другой валюты не считаются; `null` sector/issuer не считается | PC:144 |
| I13 | Название — представление, не идентификатор: разные `issuer.id` и разные `sector` не объединяются при одинаковой подписи | PC:96, PC:364 |
| I14 | Нет учтённых элементов — пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | PC:451 |
| I15 | `sector` и `issuer` отличны от `null` только у STOCK/BOND/FUND; значения обрезаны; `issuer` — пара целиком | PVΔ:3 |
| I16 | Поле и отсчёт 24 часов после получения определяются только таблицей PVΔ:156: ни один исход, кроме строк «Получено значение» и «Ответ „не найдено“ или значения нет», не меняет значение и не сдвигает отсчёт | PVΔ:156, PVΔ:141 |
Доля может быть периодической дробью. Требование 100 % относится к точным суммам, а не к сумме показанных процентов; последнюю долю подгонять нельзя. Текущий frontend helper использует `Number` (`frontend/src/entities/portfolio/model/display.ts:7-24`): он задаёт стиль показа, но не доказывает достаточную точность. Способ точного расчёта — design.
## Риски двойного учёта и классификации
| Ситуация | Риск | Правило |
| --- | --- | --- |
| Обыкновенная акция, привилегированная акция и облигации с одним `brand.uid` | Разные компании либо слияние самих позиций | В «Эмитентах» суммируется один `id`; в «Акциях» и «Облигациях» позиции остаются отдельными |
| Одна компания под разными брендами | Занижение концентрации | Разные `id` не объединять; эмитент — бренд T-Bank, а не юридическое лицо. Покрытие — Q-D1 |
| Разные бренды с одинаковым `name` | Слияние по подписи | Два элемента (PC:96) |
| Один `brand.uid`, разные `name` у позиций | Неоднозначное имя | Имя позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавиту (PC:114) |
| Фонд с тем же `brand.uid`, что у акции | Повторный учёт или учёт фонда как компании | `issuer` фонда есть в API, но «Эмитенты» его отбрасывают; в сумму эмитента фонд не входит |
| Фонд с `sector` | Раскладка состава фонда | Используется только `Etf.sector` позиции; состав, `focus_type`, название и управляющая компания не используются |
| Один бренд, разные сектора у акции и облигации | Подмена сектора эмитента | Каждая позиция в своём `sector`; сектор из `issuer` не выводится |
| Государственная облигация | Классификация по типу или названию | «Государственный сектор» только при `sector` = `government` |
| Cash, `CURRENCY` и денежный фонд | Двойной учёт денег | Cash и `CURRENCY` вне концентрации; фонд — по общим правилам |
| Повторяющийся FIGI | Дедупликация настоящих строк | Позиции не удаляются; повтор строки upstream и повтор доставки lookup — разные явления |
| Одна позиция в четырёх разрезах | Общий итог разрезов | Разрезы независимы, между ними не складывать |
| Раскрытое «Прочее» | Двойной учёт остатка | Детализация той же суммы, не новые сегменты |
## Ошибки преобразования
| Ошибка | Распознавание | Данные |
| --- | --- | --- |
| Нет надёжного UID/FIGI | Отсутствие ключа | Значение не присваивается; поле остаётся прежним или `null` |
| Ответ другого инструмента или актива | Несовпадение UID / FIGI / `asset_uid` с запросом | Исход «ответ о другом инструменте или активе» по PVΔ:156 |
| Пустой / только пробелы `sector` | Результат обрезки | Признак «значения нет» |
| Новый неизвестный непустой `sector` | Вне таблицы PC:374 | Не ошибка: код сохраняется и показывается как есть |
| Нет `asset_uid`, актива или бренда | Отсутствие звена цепочки | Признак «значения нет» |
| Пустые `brand.uid` или `brand.name` после обрезки | Непригодная пара | Признак «значения нет» для `issuer` целиком |
| `Brand.sector` использован вместо `Share/Bond/Etf.sector` | Нарушение M1 | Ошибка маппинга, даже если значения совпадают |
| Приведение кода к lowercase, перевод кода при выдаче | Изменённый ключ | Ошибка контракта; перевод только в подписи frontend |
| Старый JSON без ключей | Ключей нет | `null` на HTTP-границе |
| Потеря точности суммы, доля после округления | Нарушение I5, I9 | Округлённые подписи не служат источником данных |
| Тип вне STOCK/BOND/FUND имеет `sector`/`issuer` | Нарушение I15 | Принудительно `null` |
Сетевые ошибки, повторы, сроки и восстановление — [failure-model.md](failure-model.md).
## Открытые вопросы
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
- Пробелы — края обрезаются, значение из одних пробелов отсутствует (PVΔ:3; 3081).
- `issuer` у фонда — заполняется для STOCK/BOND/FUND (PVΔ:3), разрез фонд отбрасывает (PC:69) (3081).
- Разные названия одного бренда — название позиции с наибольшим `currentValue`; при равных — по алфавиту (PC:114; 3081, 3082).
- «Не найдено» от T-Bank — как «значения нет» (PVΔ:156; 3082).
- Исключение из правила «справочные сведения онлайн» для сектора и бренда (PVΔ:104; 3082).
Остаётся неизвестным. Это факты данных, они проверяются при реализации и решения владельца не требуют.
| ID | Что неизвестно | Чем проверяется |
| --- | --- | --- |
| Q-D1 | Доля позиций STOCK / BOND / FUND, у которых есть пригодная пара `brand.uid` / `brand.name`; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | Одноразовая read-only проверка на реальном Portfolio владельца в начале реализации: доля позиций с непустой парой и таблица «эмитент → типы бумаг». Низкое покрытие scope не меняет (`proposal.md:17`), но определяет, как будет выглядеть разрез на реальных данных |
| Q-D2 | Какие `sector` реально отдаются для фондов и государственных облигаций | Та же проверка: список различных значений `sector` по типам со счётчиками; сверка с таблицей PC:374. Неизвестные коды обрабатываются по PC:364 и решения не требуют |
@@ -0,0 +1,143 @@
# Documentation Impact
## Summary
Change `account-top-positions` (Issue [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48)) добавляет на страницу одного счёта вкладку «Концентрация» с разрезами «Акции», «Облигации», «Секторы», «Эмитенты», а в позиции Portfolio — сектор и эмитента (бренд актива). Для docs это значит:
- В AS-IS появляются новые понятия и правила: вкладка, разрезы, «Прочее», сектор, эмитент (бренд актива во вкладке), правило 24 часов для сектора и бренда.
- Сектор и бренд — исключение из правила «справочные сведения только онлайн» ([ADR 019](../../../../../docs/adr/019-hourly-reads-and-online-instrument-reference.md)). Решение оформляется новым [ADR 022](../../../../../docs/adr/022-stored-instrument-classification.md); текст ADR 019 не переписывается, в его статус добавляется ссылка.
- Хранение решено в design (B1): отдельная глобальная таблица `TbankInstrumentClassification`, не связанная с подключением. Её состав и поведение при отказах отражаются в `containers.md` и `data-freshness.md`.
- Правки AS-IS — после реализации и её слияния в `develop`. До этого docs не описывает поведение, которого в системе нет.
Пути ниже — относительно корня workspace `WealthLedger/`; для docs-репозитория полный путь — `docs/<path>`.
## Documentation Changes
| Action | Target | Category | Итоговое AS-IS | Source | Verification |
| --- | --- | --- | --- | --- | --- |
| Изменить | `docs/domains/portfolio/overview.md`, разделы «Снимок и связанные области» и «Как читать экран» | Domain | На странице одного счёта рядом с «Позиции» есть вкладка «Концентрация». В ней четыре разреза: «Акции», «Облигации», «Секторы», «Эмитенты». По умолчанию открыт «Акции» и первая по ISO валюта. В каждом разрезе топ-5 элементов; при семи и более элементах остальные — в «Прочее», раскрытие не меняет топ-5. Суммы и доли — в одной валюте, конвертации нет. Вкладки нет в режиме «Все счета» и при загрузке, ошибке или пустом Portfolio. Вкладка не меняет вкладку по умолчанию. Деньги не входят в знаменатель долей (в отличие от «Структуры») | `specs/.../portfolio-concentration/spec.md` (Вкладка, Состояния, Переключение, Начальное состояние, Одна валюта); `proposal.md` What Changes; `design.md` D8–D10 | Сверка с мерджнутым frontend и итоговыми Specs; проверка ссылок |
| Добавить | `docs/domains/portfolio/concentration.md` (новый файл, если правила не умещаются в `overview.md`; решение при написании) | Domain / reference | Состав разрезов по типам позиций; топ-5 и «Прочее»; группы «Сектор не указан», «Эмитент не указан»; значение `government` с подписью «Государственный сектор»; правила долей и одной валюты | `specs/.../portfolio-concentration/spec.md` (Состав разрезов, Названия элементов); `proposal.md` | Линк из `overview.md` и `docs/README.md`; markdownlint |
| Изменить | `docs/domains/portfolio/data-reference.md`, таблица «Состав и денежные показатели»; раздел «Структура и доли» | Reference | Позиция получает `sector` (сектор T-Bank без перекодирования, пробелы обрезаны, пустое → `null`; только `STOCK`, `BOND`, `FUND`) и `issuer` (бренд актива T-Bank; `null` вне разрезов; фонды в разрез «Эмитенты» не входят). `logoName` не меняется. В «Структуре» отметить: знаменатель и валютные итоги включают деньги, вкладка «Концентрация» — нет | `specs/.../portfolio-view/spec.md` «Нормализованный состав Portfolio» (PVΔ:3); `data-mapping.md` M1, M2; `design.md` D7 | Сверка с кодом ответа и с OpenAPI, выпущенным backend; DTO не копируется |
| Изменить | `docs/domains/portfolio/flows/portfolio-loading.md`, разделы «Основной обмен» и «Ошибки и результат для пользователя» | Flow | Сектор и бренд получаются в том же часовом обходе, в шаге до публикации Snapshot: после GetPortfolio и GetPositions. Первая попытка по позиции — не раньше чем через 24 часа после последнего успешного получения; пока значение не получено, попытка повторяется при каждом обходе. Ошибка получения не мешает публикации Snapshot и не меняет `isStale` и `lastSuccessfulAt`, кроме отказа token: он даёт `400 INVALID_TBANK_TOKEN` без fallback. Сохранённое значение остаётся доступным. Обычный GET по-прежнему не обращается к T-Bank. Фраза «Metadata инструмента дополняет представление независимо» уточняется: сектор и эмитент входят в Snapshot | PVΔ:104, PVΔ:141, PVΔ:156; `failure-model.md`; `design.md` B2, D1–D5 | Сверка с таблицей исходов PVΔ:156 и с кодом после слияния |
| Изменить | `docs/integrations/tbank.md`, таблица «Данные и ответственность»; раздел «Публичные логотипы» | Integration | Новые данные из T-Bank: сектор инструментов акций, облигаций и фондов (вызовы `ShareBy`, `BondBy`, `EtfBy`); бренд актива (uid и название) через `GetAssetBy` по `asset_uid`. Эмитент не выводится из названия, тикера или ISIN. Логотип по-прежнему приходит из metadata инструмента (`logoName`) и не равен эмитенту. Вызовы проходят через общий лимитер (по умолчанию 5/с) и ограниченную очередь | `data-mapping.md` «Источники-кандидаты в T-Bank»; `design.md` B4, D6 | Сверка с кодом T-Bank-клиента после слияния; ссылки на CDN не меняются |
| Изменить | `docs/architecture/cross-cutting/data-freshness.md`, раздел «Клиентский и серверный снимки» (список данных SQLite) и абзац про часовой обход | Architecture (cross-cutting) | SQLite дополнительно хранит классификацию инструментов (сектор и бренд) в глобальной таблице `TbankInstrumentClassification`: она не принадлежит пользователю или подключению и не ограждается по connection revision. Каждое поле имеет свой возраст (`sectorFetchedAt`, `brandFetchedAt`). Классификация не входит в `meta.dependencies` и не меняет свежесть Snapshot. Прежнее утверждение «общие сведения об инструментах запрашиваются онлайн» получает оговорку про это исключение и ссылку на ADR 022 | `design.md` Data / Storage Changes, D1, D4; `design.md` Cross-cutting «Freshness»; PVΔ:104, PVΔ:3 | Сверка с миграцией Prisma после слияния; проверка ссылки на ADR 022 |
| Изменить | `docs/architecture/containers.md`, раздел «Хранимые данные и границы доступа» (абзац про portfolio и общие сведения) | Architecture (data) | Добавить: SQLite содержит таблицу `TbankInstrumentClassification` (провайдер, ключ инструмента, сектор с возрастом, uid актива и бренда с именем и возрастом). Данные публичные, без токена и позиций. Снимок Portfolio полей классификации отдельно не хранит: поля `sector` и `issuer` позиций заполняются из таблицы при публикации | `design.md` Data / Storage Changes; B2; PVΔ «Ограничения доступа» | Сверка с `schema.prisma` и миграцией после слияния |
| Изменить | `docs/glossary.md`, блок Portfolio (после «Currency Instrument Position», до «Instrument»): новые термины | Glossary | См. раздел «Glossary / Navigation Impact» | `proposal.md`; `specs/.../portfolio-concentration/spec.md`; решение владельца (3083) | Формат: `## Термин`, определение и «_Не использовать_»; ссылки на домен |
| Изменить | `docs/overview.md`, таблица «Основные задачи пользователя», строка «Понять текущий состав инвестиций» | Navigation | Добавить задачу «увидеть, где сконцентрированы деньги: бумаги, секторы, эмитенты» со ссылкой на портфель | `proposal.md` (Why) | markdownlint, relative-links |
| Изменить | `docs/README.md`, таблица «Предметные области», строка «Портфель» | Navigation | Строка «Позиции, остатки, суммы, структура и свежесть» дополняется концентрацией (и ссылкой на новый файл, если он появится) | Как выше | markdownlint, relative-links |
| Новый ADR | `docs/adr/022-stored-instrument-classification.md` и строка в `docs/adr/README.md` | ADR | Фиксирует исключение: сектор и бренд актива хранятся как последнее известное значение с отдельным возрастом, а не запрашиваются онлайн. Область: сектор для `STOCK`/`BOND`/`FUND`, бренд актива; 24-часовой отсчёт; сохранение при ошибке; глобальная таблица без привязки к подключению | `design.md` B1, D1–D2; PVΔ:104; ADR 019; решение владельца (3083) | Номер и отсутствие дубликата проверить на момент записи; ссылки проходят |
| Изменить (связь) | `docs/adr/019-hourly-reads-and-online-instrument-reference.md`, строка **Status** | ADR | Добавить: «дополнен [ADR 022](022-stored-instrument-classification.md) в части сектора и бренда актива позиции». Текст решения 019 не переписывается | `docs/CONTRIBUTING.md` («Подтверждённо заменённое решение связывается с новым, а не переписывается») | Ссылка открывается; markdownlint |
| Изменить (условно) | `docs/domains/portfolio/all-accounts.md`, раздел «Маршруты и навигация» | Navigation | Одна строка: вкладки «Концентрация» в общем режиме нет. Если владелец решит, что это не нужно описывать — не менять | `specs/.../portfolio-concentration/spec.md` («Режим «Все счета»») | Решение при написании |
### Что не меняется (и почему)
| Target | Причина |
| --- | --- |
| `docs/architecture/containers.md`, Mermaid-диаграмма Container и раздел «Контейнерная поставка» | Новых внешних систем и контейнеров нет; T-Bank Brands CDN используется только для изображений, как и раньше |
| `docs/architecture/context.md`, Mermaid-диаграмма и текст | Тот же набор внешних систем |
| `docs/architecture/http-api.md` | Контракт генерирует backend (OpenAPI). Новые необязательные поля не копируются в docs |
| `docs/domains/accounts/overview.md` | Перечня вкладок страницы счёта в docs нет; вкладка относится к портфелю |
| `docs/domains/instruments/stocks.md`, `docs/domains/instruments/bonds.md` | Сектор и бренд показываются только во вкладке счёта. Файлы **не читались целиком** — проверить поиском «сектор» перед PR |
| `docs/domains/portfolio/all-accounts.md` (кроме условной строки выше) | Общий режим без вкладки; новых маршрутов нет |
| `docs/CHANGELOG.md` | Генерируется release-it из коммитов; вручную не править |
| `docs/architecture/cross-cutting/ui-resilience.md` | Не читалось полностью. Проверить, описывает ли оно состояния страницы счёта |
## Diagram Promotion
Перенос диаграмм не требуется. По умолчанию перенос не делается (`schema.yaml`, `docs-impact`; `docs/CONTRIBUTING.md`).
- `analysis/ux-design.md`, Mermaid flowchart «Страница счёта → Концентрация» — UI-поток этого change, не системная архитектура. В docs не переносится.
- `visuals/*.svg` — материалы Visual Package; в docs не размещаются.
- Существующие диаграммы (Container, Context, sequence подключения T-Bank) не меняются.
- Sequence «часовой обход → классификация → Snapshot» в docs **не добавляется**: поток описан текстом в `portfolio-loading.md`. Если понадобится, её делает автор docs-PR по `design.md` B2.
## ADR Decision
**Практика ADR в docs есть:** журнал `docs/adr/`, индекс в `docs/adr/README.md`, последний номер — 021, следующий — **022**. Формат — по ADR 019: `# NNN. Название`, `**Status:**`, `## Decision`, `## Consequences`. Принятый ADR не переписывается; замена связывается ссылкой на новый (`docs/CONTRIBUTING.md`).
**Решение владельца (3083):** добавить ADR для хранения сектора и бренда как исключения из online-only справочных данных.
**ADR 022 — `docs/adr/022-stored-instrument-classification.md`, статус `Accepted`.** Содержание, подтверждённое источниками:
- Context: ADR 019 (справочные сведения онлайн; старые строки не источник актуальности); PVΔ:104 (исключение для сектора и бренда); design B1 (общий metadata-кэш не подходит: `Instrument` не содержит сектора, `brand` — `BrandData`, а не `Brand{uid,name}`).
- Decision: глобальная таблица `TbankInstrumentClassification` с последним известным значением сектора и бренда, возраст полей раздельно; обновление по правилу PVΔ:141 (не раньше 24 ч после последнего успешного получения); четыре исхода PVΔ:156; без привязки к пользователю и подключению.
- Consequences: первый долговременный справочник T-Bank в системе; число вызовов T-Bank растёт при первом обходе (разовый всплеск, ограничен D6); отказ token обрабатывается как в обходе Portfolio; пересмотр, если появится второй потребитель (design ADR Candidates).
- Альтернативы: design Solution Assessment, варианты B и C. В ADR описываются кратко, со ссылкой на design.
ADR 022 пишется в том же docs-PR, что и изменения AS-IS, или раньше — и тогда в статус ADR 019 добавляется ссылка в том же PR, что и ADR 022.
## Glossary / Navigation Impact
### Глоссарий (`docs/glossary.md`)
Новые термины — в блоке Portfolio (после «Currency Instrument Position», до «Instrument»). Формат: `## Термин`, краткое определение, строка `_Не использовать_`.
| Термин | Что писать |
| --- | --- |
| **Концентрация** | Вкладка страницы одного счёта с разрезами по бумагам, секторам и эмитентам текущего Portfolio. Ссылка на домен портфеля. Не «структура» |
| **Разрез** | Одна из четырёх группировок вкладки «Концентрация»: набор элементов, на которые делится оценка позиций |
| **Сектор** | Значение сектора T-Bank для позиций `STOCK`, `BOND`, `FUND`, без собственной классификации WealthLedger. Государственные облигации — значение `government` с подписью «Государственный сектор» |
| **Эмитент** | В разрезе «Эмитенты» вкладки «Концентрация» — бренд актива по данным T-Bank. Фонды в разрез не входят. **Оговорка (решение владельца 3083):** в этом разрезе слово означает бренд актива T-Bank, а не юридического эмитента ценной бумаги. Строка «_Не использовать_»: юридический эмитент, эмитент рейтинга (см. [рейтинг выпуска НКР](integrations/nkr.md)) |
| **«Прочее»** | Группа остальных элементов разреза при семи и более элементах. Это UI-подпись: описывается в доменной странице, в глоссарии — одна строка-ссылка |
Владелец оставил термин «Эмитенты». Конфликт с юридическим эмитентом в NKR, ADR 013, `context.md` и `bonds.md` сохраняется; оговорка в глоссарии — единственное место, где он снимается. Остальные страницы, использующие «эмитент» в значении рейтинга или выпуска, не меняются.
Не добавлять отдельными записями: «Сектор не указан», «Эмитент не указан», «Государственный сектор» — UI-подписи, описываются в доменной странице.
Связанная существующая запись: «Instrument Logo» (строка про `logoName`) — после change явно указать, что логотип не равен эмитенту.
### Навигация
- `docs/domains/portfolio/overview.md` — главная страница для концентрации; ссылки на `data-reference.md` и новый файл (если будет).
- `docs/overview.md`, `docs/README.md` — строки из таблицы изменений.
- `docs/architecture/README.md` — новых вопросов не добавляется.
- `docs/adr/README.md` — строка 022 в индексе.
## Порядок относительно реализации
Порядок из `docs/CONTRIBUTING.md` («Фазы работы»), `docs/AGENTS.md` и правила AS-IS. Design задаёт выкат: backup, миграция `CREATE TABLE`, backend, публикация OpenAPI, frontend (`design.md`, Migration / Rollout).
1. **Сейчас (G2 approved, G3 — design package):** AS-IS страницы не меняются. Создать пункты `[docs]` в `tasks.md` по таблице выше.
2. **Во время реализации:** черновики текста допустимы в issue-linked ветке docs от `develop`. ADR 022 — в статусе `Accepted` (решение принято владельцем 3083).
3. **Слияние в `develop` docs-PR** — после слияния backend и frontend и проверки на окружении: AS-IS-тексты не должны обещать поведение, которого в `develop` нет. ADR 022 можно слить раньше, если его текст не описывает поведение, а фиксирует решение.
4. **Перед завершением:** сверка «текст ↔ код ↔ Specs»; `docs-sync-audit` или ручная проверка; проверки из следующего раздела.
Заголовок PR — conventional commit (`docs/commitlint.config.cjs`; CI `commits.yml` запускает `commitlint --strict`). В описании PR — ссылка на Issue #48 и change-id.
## AS-IS правила
- Docs описывает только поведение, которое есть в `develop` и проверено. Спецификация change не копируется в docs как требование.
- Формулировки «будет», «планируется» в AS-IS-страницах не используются.
- Текущие утверждения, которые станут неверными после change (исправлять только после реализации):
- `docs/architecture/cross-cutting/data-freshness.md` — список данных SQLite и онлайн-правило для инструментов;
- `docs/domains/portfolio/flows/portfolio-loading.md` — «Metadata инструмента дополняет представление независимо»;
- `docs/adr/019-…` — онлайн-правило: исключение описывает ADR 022; текст 019 не переписывать;
- `docs/domains/portfolio/data-reference.md` — «Структура» и «Концентрация» не должны выглядеть одним показателем.
- Один факт — один источник: правила сектора и бренда живут в `portfolio-view` Specs; docs ссылается на них, а таблицы исходов из `failure-model.md` не копирует.
- Имена RPC T-Bank в docs упоминаются только как категории источников; точный контракт — код и `design.md`.
## Проверки
Из корня docs (`docs/`), по `docs/CONTRIBUTING.md` и `docs/AGENTS.md`:
```bash
nvm use --silent # если nvm не загружен, сначала загрузить $HOME/.nvm/nvm.sh (см. корневой AGENTS)
npm ci # если зависимости не установлены
npm run verify # = npm run lint && git diff --check
```
- `npm run lint`: markdownlint по `**/*.md` (кроме `node_modules` и `CHANGELOG.md`) с правилом относительных ссылок; отдельно `CHANGELOG.md`.
- Коммит: `npm run commitlint -- --strict`.
- Ручные проверки, которые lint не делает: якоря в ссылках; смысловая сверка с кодом и Specs; маршрут чтения в `README.md`; новый файл достижим из индекса.
- **Рендер Mermaid не проверяется** ни одним инструментом из `package.json`. Если меняется диаграмма, отмечать «не проверено рендером».
- Для ADR 022: номер, отсутствие дубликата в `docs/adr/README.md`, ссылка из ADR 019.
В этой задаче проверки не запускались: правок в docs нет.
## Открытые вопросы и расхождения
1. **Конфликт термина «эмитент»** сохраняется вне вкладки: NKR, ADR 013, `context.md`, `bonds.md` используют слово для юридического эмитента. Оговорка в глоссарии снимает конфликт только для вкладки; остальные страницы — решение владельца, если оно понадобится.
2. **Конфликт с ADR 019** снимается ADR 022 (решение 3083). Текст ADR 019 остаётся историческим.
3. **Не полностью прочитаны:** `docs/domains/instruments/stocks.md`, `bonds.md`, `docs/architecture/cross-cutting/ui-resilience.md`, `docs/integrations/README.md`. Проверить поиском перед PR.
4. **Основание решения 3083** в этом файле не цитируется дословно: текст комментария Issue не читался в этой задаче. Перед написанием ADR 022 сверить формулировку с комментарием.
5. **Вне scope:** `docs/code-quality-review.md` лежит в корне docs, хотя `docs/AGENTS.md` запрещает аудиты в docs. Не трогается.
@@ -0,0 +1,144 @@
# Модель отказов
## Контекст и границы
Модель описывает получение сектора и бренда актива в часовом обходе, а не новый процесс синхронизации. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](system-analysis.md). Источники и преобразования — [data-mapping.md](data-mapping.md). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
Нормативная основа. Поведение при получении сектора и бренда определяют Specs; модель их не пересказывает и не дополняет, а относит отказы T-Bank к соответствующим исходам:
- PVΔ:141 «Обновление сектора и эмитента» — когда получение происходит.
- PVΔ:156 «Результат получения сектора и эмитента» — четыре исхода; для каждого таблица задаёт поле позиции и отсчёт 24 часов. Неуспешное получение не мешает обновлению остальных данных Portfolio.
- PVΔ:104 «Свежесть и fallback полного Portfolio Snapshot» — хранимое последнее известное значение и исключение «отказ token».
- PVΔ:3 «Нормализованный состав Portfolio» — сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность и свежесть Snapshot, кроме отказа token.
Финансовый Snapshot по-прежнему требует успешных GetPortfolio и GetPositions (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`; `backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-283`). Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank/tbank-data-sync/spec.md:9-14`). Новых очередей, блокировок, таблиц и библиотек модель не вводит; их выбор — design.
## Классификация отказов по исходам PVΔ:156
Для сопоставления отказов с нормой используется только эта таблица; значения полей и отсчёт в неё не копируются.
| Наблюдаемое событие | Исход по PVΔ:156 |
| --- | --- |
| Получено непустое значение | «Получено значение» |
| Ответ «не найдено» (в том числе NOT_FOUND на уровне RPC), нет поля / пусто / только пробелы после обрезки, нет `asset_uid`, актива или бренда, непригодная пара `brand.uid` / `brand.name` | «Ответ „не найдено“ или значения нет» |
| Ответ с UID / FIGI / `asset_uid` не запрошенного инструмента или актива | «Ответ о другом инструменте или активе» |
| Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, переполнение локальной очереди, ошибка декодирования, любой иной код, кроме отказа token | «Иная ошибка, кроме отказа token» |
| PERMISSION_DENIED, UNAUTHENTICATED (сведены клиентом к InvalidToken) | Отказ token: не из таблицы, обработка по PVΔ:104 |
Следствие: таблица PVΔ:156 содержит ровно четыре исхода плюс отказ token; других вариантов поведения модель не вводит.
## Сценарии отказов
«Известное значение» — значение, которое сохранено в позиции по PVΔ:104. Сектор и бренд получаются независимо.
| ID | Граница / шаг | Отказ | Обнаружение | Требуемое поведение системы | Наблюдаемый результат | Requirement |
| --- | --- | --- | --- | --- | --- | --- |
| F01 | T-Bank → sync: финансовые ответы | GetPortfolio или GetPositions не получен | Исход RPC | Новый полный Snapshot не публикуется; действует прежний fallback Portfolio. Успех получения sector/brand его не заменяет | Прежний Snapshot с `isStale` либо отсутствие данных | `portfolio-view:96` (canonical); `hourly-investment-sync:8` |
| F02 | Получение сектора или бренда | Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, ошибка декодирования — иная ошибка, кроме отказа token | Код RPC после попыток клиента | По строке «Иная ошибка, кроме отказа token» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156 |
| F03 | Получение бренда (инструмент → `GetAssetBy`) | То же для любого из двух запросов цепочки | Исход RPC | Как F02 — по той же строке PVΔ:156; сектор и бренд независимы | Как F02 | PVΔ:156 |
| F04 | Успешный ответ без значения, «не найдено» | Нет `sector`, `asset_uid`, `brand`, пусто или только пробелы; ответ «не найдено» | Валидация маппинга (признаки — data-mapping); код NOT_FOUND | По строке «Ответ „не найдено“ или значения нет» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (обрезка пробелов) |
| F05 | Идентификация | Ответ о другом инструменте или активе | Сравнение UID / FIGI / `asset_uid` с запросом | По строке «Ответ о другом инструменте или активе» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (запрет определять эмитента по названию) |
| F06 | Обогащение набора позиций | Часть позиций получена, часть нет | Результат по каждому полю | Каждое поле — по своему исходу PVΔ:156; Snapshot публикуется целиком | Известные и неизвестные группы наравне; суммы не меняются | PVΔ:156; PC:349 |
| F07 | Лимитер / квота T-Bank | RESOURCE_EXHAUSTED либо переполнение локальной очереди | Код RPC; локальный отказ admission | Ограниченные повторы клиента (см. «Таймауты и повторы»); после них — F02 | Как F02 | PVΔ:156 |
| F08 | Отказ token при получении | PERMISSION_DENIED или UNAUTHENTICATED из запроса сектора или бренда | Код gRPC → InvalidToken | Обрабатывается так же, как отказ token при обходе Portfolio | Как задано PVΔ:104 (сценарий «Отказ token при получении бренда»); повторов в этом обходе нет | PVΔ:104 |
| F09 | Публикация финансового Snapshot | Транзакция не завершена | Отказ публикации | Нового успеха нет, остаётся прежний Snapshot; обогащение не маскирует отказ | Нет ложного `lastSuccessfulAt` | `portfolio-view:96` (canonical) |
| F10 | Сохранение полученных sector/brand | Данные получены, но не сохранены | Исход записи; несовпадение revision | Финансовый успех не отменяется. Значение не считается полученным: исход, который его сохранил бы, не наступил, поэтому поле и отсчёт 24 часов остаются прежними, а следующий обход повторяет получение, пока значение не получено (PVΔ:141) | Прежнее значение либо `null` | PVΔ:141; PVΔ:104 |
| F11 | Границы подключения | Token заменён, подключение удалено, счёт недоступен | Revision, membership, attempt | Поздний результат прежнего подключения не публикуется в новое; хранимые sector/brand относятся к подключению и не переходят в новое | Прежние `404`/`409`/`400`; нет утечки данных | `portfolio-view:99` (canonical); PVΔ:104; `docs/domains/portfolio/flows/portfolio-loading.md:24-29` |
| F12 | GET → frontend | Временная ошибка HTTP-чтения | Состояние Portfolio query | Сохранённые данные и разрезы остаются; HTTP-повтор читает только локальные данные. Вкладка не показывается при загрузке, ошибке и недоступности | Как на «Позициях» | PC:20; PC:484 |
| F13 | Старый JSON / Client Cached Snapshot | Ключей `sector`/`issuer` нет | Нет ключей | Трактуются как неизвестные; в ответе `null` | Группы «не указан» до ближайшего получения | PVΔ:3; PC:349 |
| F14 | Свежесть и полнота Portfolio | Snapshot устарел или `meta` обозначает неполноту, а sector/brand получены | `isStale`, `meta` | Время sector/brand не освежает финансовый Snapshot; при неполноте — единственное сообщение «Данные неполные» на вкладке, страничный Alert на этой вкладке не показывается | Разрезы по доступным данным; одно сообщение о неполноте | PC:464; PC:484 |
| F15 | Данные разреза | Нет учтённых позиций | Клиентская фильтрация | Пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | Нет NaN и фиктивных 100 % | PC:451; PC:129 |
| F16 | Долгая недоступность | Получение не удаётся дольше 24 часов | Время последнего успешного получения | По строке «Иная ошибка, кроме отказа token» PVΔ:156 каждый обход; предельного возраста у хранимого значения нет | Значение может отставать дольше 24 ч + интервал обхода, пока T-Bank не ответит | PVΔ:156; PVΔ:141; PVΔ:104 |
Группа «не указан» не различает причину: отсутствие у T-Bank, технический сбой, старый Snapshot. Новой пользовательской ошибки или статуса качества не вводится. Диагностика — существующие логи синхронизации (`specs/openspec/changes/account-top-positions/process-record.md:15-17`).
## Таймауты и повторы
### Подтверждённые ограничения AS-IS
| Механизм | Факт | Источник |
| --- | --- | --- |
| Deadline одного RPC | 15 000 мс | `backend/src/integrations/tbank/tbank.client.ts:55,589-613` |
| Попытки одного вызова | Максимум 2; пауза 1 000 мс | `backend/src/integrations/tbank/tbank.client.ts:51-53,518-562` |
| Повторяемые коды | DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, UNAVAILABLE | `backend/src/integrations/tbank/tbank.client.ts:548-552` |
| Прочее | PERMISSION_DENIED / UNAUTHENTICATED → InvalidToken; NOT_FOUND → `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальное → Unavailable | `backend/src/integrations/tbank/tbank.client.ts:571-583` |
| Очередь и скорость | Общий лимитер клиента, очередь 50, по умолчанию 5 стартов/с (локальная настройка, не квота T-Bank) | `backend/src/integrations/tbank/tbank.client.ts:50-52,147-187,259-261`; `backend/src/config/env.validation.ts:5,29-31` |
| Ожидание шага обхода | 900 000 мс; истечение не отменяет операцию | `backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:28-33` |
| Часовой обход | Не ранее 60 минут от старта прошлой попытки; без очереди пропущенных и без перекрытий | `specs/openspec/specs/tbank/hourly-investment-sync/spec.md:8-21` |
Две сетевые попытки по 15 с с паузой дают до 31 с без ожидания лимитера. Это не общий timeout цепочки «инструмент → актив». 15 минут планировщика не являются обещанием срока получения sector/brand.
### Требования, вытекающие из Specs
- Момент получения и повторы между обходами определяет PVΔ:141: получение при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе. Внутри обхода повторы — только прежние до двух попыток клиента. Новые циклы по открытию экрана, HTTP-повтору или раскрытию «Прочее» запрещены (`tbank-data-sync:9-14`).
- Обходы, начавшиеся раньше срока, значение не меняют (PVΔ:141, сценарий «Обход раньше 24 часов»); значения, у которых срок истёк одновременно, получаются в одном обходе.
- **Требование к реализации, не выбор.** NOT_FOUND от `EtfBy`, `BondBy` и `GetAssetBy` относится к исходу «значения нет» (PVΔ:156, решение 3082 «Как „значения нет“»). Текущий клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальное — в Unavailable (`tbank.client.ts:577-583`); без изменения такой ответ был бы ошибочно отнесён к «иной ошибке». Реальный вид ответа «не найдено» у каждого метода проверяется как Q-F3.
- Числовой общий бюджет времени получения и новый SLA Specs не задают; публикация финансов не должна ждать получения (PVΔ:3). Распределение вызовов внутри обхода — design.
### Число дополнительных вызовов
Обозначения: **P** — число позиций STOCK/BOND/FUND; **U** — число различных инструментов среди них; **A** — число различных `asset_uid`. У остальных типов вызовов нет.
- Без повторного использования результатов точечная схема даёт до `2P` логических вызовов: типизированный инструмент (sector и `asset_uid`) и `GetAssetBy` (бренд).
- С повторным использованием: до `U + A` в обход, когда срок истёк; при двух попытках клиента — до `2 × (U + A)` сетевых попыток. Если нужен ещё `GetInstrumentBy` для идентификации либо FIGI-fallback, число растёт. Оценка не выбирает алгоритм и не вводит квоту.
- При тёплых значениях — близко к нулю между суточными сроками; при систематических ошибках — до `U + A` каждый обход.
- `GetAssets` заменить `GetAssetBy` не может: в `Asset` нет `brand` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:1075-1080`).
- 5 стартов/с делится с остальными вызовами клиента; соответствие внешним квотам не доказано (Q-F3).
## Идемпотентность и обработка дубликатов
Все внешние вызовы — чтения: повтор не создаёт активов и позиций и не меняет стоимость. Повтор получения заменяет значение поля по PVΔ:156, но не добавляет вторую позицию.
Публикация финансов защищена в AS-IS: scope — пользователь + revision подключения + счёт; работа ограждена `id`, `attemptId` и состоянием `running`; поздняя публикация завершённой попытки не проходит (`backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:13-18,116-150`). HTTP-ключ идемпотентности для GET не нужен.
Для получения sector/brand ключ соответствия — провайдер T-Bank + instrument UID (затем FIGI); для бренда — `asset_uid`; для группировки на клиенте — `issuer.id`. Текущий `metadataKey` и объединение запросов по ключу (`backend/src/integrations/tbank/instrument-metadata.repository.ts:33-38`; `backend/src/integrations/tbank/instrument-metadata.service.ts:69-98`) подтверждают AS-IS, но не задают новый кэш. Обязательство design: обеспечить отсчёт срока по `PVΔ` «Обновление сектора и эмитента»; гранулярность и способ хранения времени последнего успешного получения выбирает design. Дедупликация одновременных запросов допустима, но не должна запрещать повтор, который требует PVΔ:141.
## Порядок и конкуренция
- Значение присваивается только по исходам PVΔ:156; ответ актива не применяется к произвольной позиции по названию. Сектор и бренд независимы и могут завершиться в любом порядке.
- Полнота и время финансового Snapshot определяются финансовыми ответами; одновременный GET может увидеть ещё не обновлённое значение, и это допустимо.
- Поздний результат прежнего подключения или пользователя не принимается; существующие ограждения публикации сохраняются (`portfolio.repository.ts:121-147`).
- `issuer` — пара целиком; `id` из одного ответа не сочетается с `name` из другого. Если разные позиции одного бренда несут разные `name`, выбор имени — клиентский (PC:114).
- Один инструмент на нескольких счетах использует одно определение; позиции счетов не объединяются.
- Часовые обходы не перекрываются; истёкшее ожидание не отменяет вызов, поздний ответ проверяет применимость (`hourly-investment-sync:8`).
## Частичный отказ и компенсация
При полном финансовом успехе и неполном получении публикуется весь состав с доступными значениями. Известный `sector` при неизвестном `issuer` допустим и наоборот; неудача одной позиции не затрагивает остальные. Отсутствие значения не удаляет позицию, не меняет `currentValue` и не переводит Portfolio в partial (PVΔ:3).
Компенсаций нет: чтение справочника внешних эффектов не создаёт. Откатывать успешные деньги из-за бренда нельзя. Если финансовая публикация не завершилась — это F09, а не отсутствие metadata.
Исключение — отказ token (F08): его последствия задаёт PVΔ:104; подключение становится недействительным так же, как при отказе token в обходе Portfolio (реакция AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:194-196`).
## Восстановление
1. Следующий обход, начавшийся не ранее срока PVΔ:141, либо любой следующий обход, пока значение не получено, повторяет получение; ручная синхронизация не нужна.
2. Результат каждого получения определяет PVΔ:156.
3. Хранимое значение живёт без предельного возраста (F16, PVΔ:104).
4. После замены подключения хранимые значения прежнего подключения не используются; восстановление идёт в новом подключении (F11).
5. GET между обходами читает хранимое и не вызывает T-Bank (PVΔ:104, сценарий «Сектор и бренд читаются из хранимого значения»).
6. **Расхождение AS-IS с нормами, которое закрывает реализация.** Общая metadata считается свежей по факту завершённого lookup без возраста, а запрос с `force=false` пропускает такие записи (`instrument-metadata.repository.ts:314-323`; `instrument-metadata.service.ts:75-79`). Для PVΔ:141 и PVΔ:156 это недопустимо: срок считается от времени последнего успешного получения каждого поля, а не от факта завершённого lookup.
## Совместимость во время отказа
- Старые потребители игнорируют новые поля; Money и состояния ресурса не меняются.
- Старый JSON не пересоздаётся; отсутствие ключей на HTTP-границе — `null`.
- Если metadata нет совсем, положительные позиции образуют группы «не указан», а не пустое состояние. Пустое состояние — только при отсутствии учтённых позиций (PC:451).
- Client cache не обходит `404`, отсутствие подключения и известный недействительный token (`specs/openspec/specs/portfolio/portfolio-view/spec.md:150-157`).
- Ошибки sector/brand не попадают в `meta` Portfolio как причина неуспеха или устаревания. Отдельный статус качества metadata потребовал бы Specs.
## Открытые вопросы
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
- Срок и поведение после ошибки — PVΔ:141 и PVΔ:156.
- Отказ token — PVΔ:104.
- «Не найдено» от T-Bank — исход «значения нет» (PVΔ:156).
- Хранение вопреки «справочные сведения онлайн» — исключение для сектора и бренда (PVΔ:104).
- Два предупреждения о неполноте — только на вкладке (PC:464).
Остаётся неизвестным. Это факт о внешней системе и нагрузке; он проверяется при реализации и решения владельца не требует.
| ID | Что неизвестно | Чем проверяется |
| --- | --- | --- |
| Q-F3 | Фактические квоты `ShareBy`, `BondBy`, `EtfBy`, `GetAssetBy` для токена владельца; число U и A; доля обращений при холодном старте; в каком виде T-Bank отдаёт «не найдено» для `EtfBy`, `BondBy`, `GetAssetBy` (код NOT_FOUND либо пустой объект) | Официальные лимиты Invest API; измерение числа вызовов и времени в первом полном обходе по событиям `tbank.rpc.*` (`tbank.client.ts:536-569`); запрос с несуществующим `asset_uid` на тестовом окружении или мок по proto. Результат определяет параллелизм и признак ответа «не найдено» в клиенте; сама норма исхода от него не зависит |
@@ -0,0 +1,175 @@
# QA Analysis
Ревизия спеков: G2 approved (comment 3082). Источник ожидаемого поведения — только
Delta Specs `portfolio-concentration` (28 ADDED, ниже PC) и `portfolio-view`
(2 MODIFIED + 2 ADDED, ниже PVΔ) этого change. Design пишется параллельно; на его
внутреннее устройство не опираемся. Case пишется только там, где нужны данные, шаги или oracle сверх Scenario;
остальные строки Coverage Matrix ссылаются на Scenarios. Написанные cases — в
`analysis/test-cases/{conc,meta,reg}.md`.
## Risk Assessment
| Риск | Затронутые требования | Вероятность | Ущерб | Глубина проверки |
| --- | --- | --- | --- | --- |
| Неверная классификация исхода получения (NOT_FOUND vs иная ошибка) ломает 24h-отсчёт и last-known | «Результат получения сектора и эмитента», F02–F05 | Высокая (клиент AS-IS отображает NOT_FOUND как Unavailable — failure-model:75) | Средний: группы «не указан» вместо значений | Интеграционные проверки всех 4 строк таблицы исходов + exploratory Q-F3 |
| Доли/суммы считаются от неверной базы (Cash Balance, нули, другие валюты, отрицательные) | PC:129, PC:144, PC:289 | Средняя | Высокий: недоверие владельца к цифрам | Компонентные проверки с числовыми оракулами из сценариев |
| Потеря/порча финансового Snapshot из-за обогащения (token, публикация, границы подключения) | «Свежесть и fallback полного Portfolio Snapshot», F08–F11 | Низкая | Критический: потеря данных портфеля | Интеграционные: token-отказ, гонка публикации, смена подключения; регрессия обхода |
| Ошибка округления/формата долей (`,0`, `< 0,1 %`, половина вверх) | PC:304, PC:324 | Средняя | Низкий | Компонентные с точными строками-эталонами |
| Ухудшение существующей вкладки «Позиций» / структуры портфеля | PC:9, регрессия | Средняя | Высокий | Регрессионный прогон (TC-REG) |
| Доступность диаграммы только цветом / недоступна скринридеру | PC:443 | Средняя | Средний | Ручная проверка + текстовый эквивалент в component |
| Квоты T-Bank при суточном обходе (до `U + A` вызовов) | F07, Q-F3 | Неизвестная | Средний: деградация синхронизации | Exploratory Q-F3 на тестовом токене; norm от квот не зависит |
## Coverage Matrix
Уровни: unit / integration (backend, configured HTTP tests) / component (frontend) / e2e / manual.
Ссылки на сценарии — `PC:<строка>` / `PVΔ:<строка>` файлов спек.
| Requirement | Сценарии / ветви | Проверка / ожидаемое наблюдение | Уровень | Test cases |
| --- | --- | --- | --- | --- |
| PC:9 Вкладка «Концентрация» страницы счёта | 2 сценария (есть вкладка; «Все счета» — нет) + инвариант «прочие вкладки не меняются» | Вкладка рядом с «Позициями»; в «Всех счетах» отсутствует; дефолтная вкладка прежняя | e2e | Scenario «Вкладка на странице одного счёта», «Режим “Все счета”»; TC-REG-001 |
| PC:20 Вкладка в состояниях страницы | загрузка/ошибка/пусто/только Cash Balance | Вкладки нет при загрузке, ошибке и пустом Portfolio; есть при любом Cash Balance | component + e2e | TC-CONC-002 + Scenario «Загрузка и ошибка» |
| PC:37 Переключение разрезов | 4 разреза, один одновременно, порядок | Показан ровно один разрез, переключатели в заданном порядке | component | Scenario «Открытие вкладки», «Переключение разреза» |
| PC:49 Начальное состояние вкладки | новый заход/reload → «Акции»+первая валюта; вкладки страницы → сохранение | Reset при новом заходе и смене счёта, persist при переключении вкладок | e2e | TC-CONC-005, TC-CONC-006 |
| PC:69 Состав разрезов | 3 сценария (сектор объединяет; эмитент stock+bond без фонда; фонд вне акций/облигаций) | Агрегация и исключение типов по таблице | component | TC-CONC-007 |
| PC:96 Названия элементов | без name → ticker; `issuer.name`; разные `issuer.id` не сливаются | Название по цепочке name→ticker→FIGI; раздельные элементы при равных именах | component | TC-CONC-008 |
| PC:114 Название эмитента при разных названиях бренда | наибольшее `currentValue`; равные → меньший по алфавиту | Элемент назван по большей позиции; при равенстве — «Сбер» < «Сбербанк» | component | Scenario «Название по наибольшей позиции», «Равные наибольшие значения» |
| PC:129 Позиции, учитываемые в разрезе | положительные; 0 — не учитывается и не неучтённая; null/отрицательные исключены | Сумма разреза только из положительных позиций выбранной валюты | component | Scenario «Отрицательная и неоценённая позиции», «Позиция с нулевой стоимостью» |
| PC:144 Сноска о не учтённых позициях | N: null в любой валюте + отрицательные в выбранной; 0 → нет сноски | Точный N по правилу; сценарии 149–167 покрывают все ветви | component | Scenario «Сноска с числом позиций», «Неоценённая позиция при смене валюты», «Отрицательная позиция другой валюты», «Нет неучтённых позиций» |
| PC:169 Одна валюта за раз | смена валюты пересчитывает; сохраняется между разрезами; нет конвертации | Значения только в выбранной валюте, без смешанного итога | component | Scenario «Смена валюты», «Валюта сохраняется при смене разреза» |
| PC:184 Доступные валюты вкладки | STOCK/BOND/FUND ненулевые, включая отрицательные; 0 исключён; Select скрыт при ≤1; сортировка ISO | Список и сортировка точны; скрытие Select; пустое состояние без выбора | component | Scenario «Начальная валюта», «Единственная валюта», «Валюта только у позиции с нулевой стоимостью», «Валюта только у отрицательной позиции», «Нет оценённых позиций» |
| PC:214 Независимый выбор валюты вкладки | независимость от «Структуры портфеля»; пропавшая валюта → первая списка | Валюта вкладки не меняется от внешнего Select; fallback при исчезновении | component + e2e | TC-CONC-015 |
| PC:229 Топ-5 и строка «Прочее» | ≤6 строк без «Прочее» (5, 6, <5); ≥7 → топ-5 + «Прочее» с числом элементов | Границы 5/6/7 и формулировка числа элементов («2 элемента», «5 элементов») | component | TC-CONC-016 |
| PC:254 Раскрытие строки «Прочее» | раскрытие/сворачивание (мышь и клавиатура); не меняет топ-5 и диаграмму; свёрнуто при показе/смене валюты | Список остальных в порядке ранжирования; инвариант неизменности топ-5 | component + e2e | Scenario «Раскрытие “Прочее”», «Повторное нажатие», «Смена валюты сворачивает “Прочее”»; клавиатура — TC-CONC-027 |
| PC:274 Порядок элементов с равной суммой | цифры < латиница < кириллица, без регистра; граница 5-й строки | Порядок «VK», «X5», «Сбер»; «Альфа» в топ-5 при равенстве | component | Scenario «Равные суммы на границе пятой строки», «Цифры, латиница и кириллица при равных суммах» |
| PC:289 Расчёт долей | точные доли суммируются в 100 %; доля «Прочее» = сумма долей элементов; Cash Balance вне | Числовые оракулы сценариев 294–302 | component | Scenario «Доля без денежных остатков», «Доля “Прочее”»; числовой прогон — TC-CONC-007 |
| PC:304 Показ долей | округление до десятых, половина вверх, без «,0», «< 0,1 %», сумма ≠ 100 % допустима | Строки-эталоны «93,8 %», «6,3 %», «< 0,1 %», 100,1 % в сумме | component | TC-CONC-020 (все три ветви: середина вверх, «< 0,1 %», без «,0», сумма ≠ 100 %) |
| PC:324 Суммарная доля топ-5 | показ при ≥6, скрыта при ≤5 (7/6/5/1 элементы) | «Топ-5 · N %» только при ≥6; значения 95 %, 96 % | component | Ветви покрыты TC-CONC-016 (наборы 1–4) |
| PC:349 Группы «Сектор/Эмитент не указан» | группа ранжируется наравне, попадает в «Прочее», имя видно | Сценарии 354–362 | component | Scenario «Позиции без эмитента в топе», «Группа без сектора в “Прочее”» |
| PC:364 Подписи секторов | известные — русская подпись, неизвестные — как получено; нет своей классификации | Неизвестный код показан буквально | component | Scenario «Неизвестное значение сектора» |
| PC:374 Русские подписи известных секторов | 11 значений таблицы, включая `government` | Каждое значение → своя подпись (таблица — эталон) | component | TC-CONC-024 |
| PC:402 Диаграмма распределения разреза | 6 сегментов при ≤6; 5+«Прочее» при ≥7; одинаково при раскрытии; нет при пустом разрезе | Число сегментов и доли совпадают со списком | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
| PC:415 Центр диаграммы | сумма точная; «Топ-5 · N %» при метрике; текстовый эквивалент | Центр: «95 %» + «1 000 ₽» / только сумма при ≤5 | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
| PC:430 Строки без переходов | клик по строке — нет навигации; эмитенты без логотипов | URL не меняется; нет `<img>` логотипов | e2e | Scenario «Нажатие на строку акции», «Строка эмитента» |
| PC:443 Доступность диаграммы без цвета | текстовый эквивалент, сопоставимость сегментов без цвета | Скринридер читает название/сумму/долю каждого сегмента | manual + component | TC-CONC-027 |
| PC:451 Пустое состояние разреза | пустой разрез: заголовок + валюта, без списка/диаграммы; сноска и выбор сохраняются | Сценарий «Валюта только у отрицательной позиции» | component | Scenario «Валюта только у отрицательной позиции» (пустое состояние) |
| PC:464 Предупреждение о неполных данных | неполнота `meta` → одно «Данные неполные» на вкладке, без дублирования на странице; неоценённые позиции не вызывают | Одно сообщение; доли по доступным данным | component + e2e | Scenario «Неполный Portfolio», «Предупреждение не дублируется» |
| PC:484 Состояния Portfolio на вкладке | соответствие «Состояниям и ошибкам Portfolio на клиенте» | isStale/загрузка/ошибка отображаются как на «Позициях» | component | Scenario «Состояния Portfolio на вкладке “Концентрация”»; сверка с canonical — существующий regression-прогон frontend |
| «Нормализованный состав Portfolio» (MODIFIED) | sector/issuer nullable; обрезка пробелов; только пробелы = отсутствие; sector только у STOCK/BOND/FUND; issuer.id+name; запрет эмитента по названию | Поля по правилам; сценарии 30–60 delta; GET-контракт не сломан | integration (configured HTTP tests) | PVΔ Scenario «Сектор инструмента доступен/отсутствует», «Эмитент позиции доступен/отсутствует», «Эмитент не определяется по названию», «Недоступность сектора и эмитента не нарушает Snapshot»; обратная совместимость GET — существующий contract-прогон |
| «Свежесть и fallback полного Portfolio Snapshot» (MODIFIED) | исключение: token-отказ при sector/brand = 400 INVALID_TBANK_TOKEN; сектор/бренд — last-known, обновление по «Обновление сектора и эмитента»; GET не ждёт T-Bank | Fallback не маскирует token-отказ; хранимые sector/brand читаются локально | integration | TC-META-002; существующий backend contract-прогон |
| «Обновление сектора и эмитента» (ADDED) | первый обход ≥24 ч после успеха; интервал из hourly-investment-sync; «не найдено» тоже успех | Сценарии 149–152: значение обновляется в первый обход после 24 ч | integration | TC-META-003 |
| «Результат получения сектора и эмитента» (ADDED) | 4 строки таблицы: значение / нет значения / другой инструмент / иная ошибка; каждая — поле + отсчёт 24 ч | Каждая строка: точное поле и отсчёт (сценарии 170–202, включая перезапуск отсчёта от «не найдено») | integration | TC-META-004 |
## Negative and Boundary Focus
- Границы элементов: 1 / 5 / 6 / 7 (строки и метрика топ-5) — TC-CONC-016.
- Границы стоимости: 0 (исключена и не в N), отрицательная (в N только своей валюты),
`null` (в N в любой валюте), крайне малая доля «< 0,1 %» — Scenario «Отрицательная и неоценённая»/«Нулевая стоимость»/«Сноска о не учтённых позициях», TC-CONC-020.
- Округление: точная половина вверх (6,25 → «6,3»), сумма показанных ≠ 100 % — TC-CONC-020.
- Валютные границы: единственная валюта (Select скрыт), валюта только отрицательной
позиции (есть в выборе, разрез пуст), валюта пропала после обновления — Scenario «Доступные валюты вкладки», TC-CONC-015.
- Тайбрейки: равные суммы (цифры/латиница/кириллица), граница 5-й строки, равные
`currentValue` у одного `issuer.id`, одинаковые `issuer.name` при разных `id` — Scenario «Порядок элементов с равной суммой»/«Название эмитента при разных названиях», TC-CONC-008.
- 24h-отсчёт: успех → сброс; «не найдено» → сброс; иной инструмент / иная ошибка →
без сброса, повтор каждый обход; систематический сбой дольше 24 ч — TC-META-003/004.
- Запрещённые эффекты: GET не запускает T-Bank, sector/brand не освежают Snapshot,
enrichment не отменяет финансовый успех, «Прочее»-раскрытие не меняет топ-5 и диаграмму,
строка не ведёт на другую страницу — TC-META-002, Scenario «Раскрытие “Прочее”», «Нажатие на строку акции».
## Cross-system Verification
T-Bank → sync → хранилище → GET → frontend. Каждый F-сценарий failure-model:
| ID | Проверка | Test cases |
| --- | --- | --- |
| F01 | Финансовый отказ не замещается успехом sector/brand; прежний Snapshot | TC-META-002, TC-REG-004 |
| F02 | Иная ошибка: прежнее значение/`null`, отсчёт не меняется | TC-META-004 |
| F03 | Сбой любого звена цепочки инструмента → бренда — как F02; поля независимы | TC-META-004 |
| F04 | «Не найдено»/пусто/пробелы → `null` + сброс отсчёта | TC-META-004 + PVΔ Scenario «Ответ без значения заменяет известное значение» |
| F05 | Чужой UID/asset_uid → прежнее значение, повтор | TC-META-004 |
| F06 | Частичное обогащение: Snapshot публикуется целиком, группы наравне | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
| F07 | RESOURCE_EXHAUSTED → как F02 после повторов клиента; (квоты — Q-F3) | TC-META-004 |
| F08 | Token-отказ при sector/brand → 400 INVALID_TBANK_TOKEN, без fallback | TC-META-002, TC-REG-004 |
| F09 | Отказ публикации: нет ложного `lastSuccessfulAt` | TC-META-002 |
| F10 | Отказ сохранения sector/brand: поле и отсчёт прежние, финансовый успех цел | TC-META-003 |
| F11 | Смена/удаление подключения: 404/409/400, sector/brand не переходят в новое | TC-META-005, TC-REG-004 |
| F12 | Ошибка HTTP-чтения на клиенте: вкладка скрыта, данные не теряются | TC-CONC-002 |
| F13 | Старый JSON без `sector`/`issuer` → `null`, группы «не указан» | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
| F14 | Время sector/brand не освежает Snapshot; неполное `meta` — одно сообщение | TC-META-002; Scenario «Неполный Portfolio» |
| F15 | Пустой разрез: нет NaN и фиктивных 100 % | Scenario «Валюта только у отрицательной позиции» |
| F16 | Сбой дольше 24 ч: значение остаётся, повтор каждый обход | TC-META-003 |
Инварианты data-mapping: сумма разреза = сумма учитываемых позиций; доли в сумме
ровно 100 % (точные); отсутствие подстановки нуля вместо `null` — точные доли Scenario «Расчёт долей» и прогоны TC-CONC-007/016.
## Test Data and Environment Needs
- **Портфель (мультивалютный, ≥10 позиций):** акции ≥7 (для «Прочее»), ровно 6 и 5
вариантов; облигации; ≥2 фонда; позиция `CURRENCY`; `FUTURE`/`OTHER` для исключения.
Валюты: RUB + USD (два Select-варианта), одиночный RUB-вариант отдельным набором.
- **Стоимости:** положительные, 0, отрицательная, `null` (в т.ч. в невыполненной валюте);
1 RUB против 100 000 000 RUB («< 0,1 %»); пары с равными суммами (граница топ-5).
- **Бренды:** один `issuer.id` с разными `issuer.name` и разными суммами; равные суммы;
два `issuer.id` с одинаковым `issuer.name`; акции + облигации одного бренда; позиции
без бренда (группа «Эмитент не указан»).
- **Секторы:** каждый из 11 кодов таблицы PC:374 (минимум `it`, `government`, `energy`),
неизвестный код (`other`), пустой/пробельный, отсутствующий; один сектор у разных типов
(STOCK+BOND+FUND); ≥6 секторов для «Прочее» в «Секторах».
- **Upstream-моки (integration):** ответы T-Bank для всех 4 исходов таблицы «Результат получения сектора и эмитента»
(значение / «не найдено» NOT_FOUND и пустой объект / чужой UID / UNAVAILABLE-timeout /
PERMISSION_DENIED-token), частичный ответ, отказ публикации; `meta` неполноты.
- Окружение: локальное backend + frontend с тестовым T-Bank-подключением; для Q-D1/Q-D2/Q-F3 —
read-only доступ к реальному токену владельца.
## Manual / Exploratory Scope
- Соответствие макетам ux-design v2 (`visuals/`): кольцевая диаграмма, центр, бары строк,
«Прочее», предупреждение — по всем четырём разрезам.
- Контраст тёмной/светлой темы: сегменты, подписи, «< 0,1 %», сноска.
- Клавиатура: Tab по переключателям разрезов, строкам и «Прочее»; Enter/Space раскрытие
(PC:254), видимый focus, отсутствие «клик-ловушек».
- Скринридер (VoiceOver/NVDA): текстовый эквивалент диаграммы — название, сумма, доля
каждого сегмента в порядке списка; центр; сопоставление сегмент↔строка без цвета (PC:443).
- Русская локаль чисел: неразрывный пробел, `₽`/`$`, запятая в долях.
- Exploratory: поведение при переключении счёта, глубоких ссылок на вкладку (если есть),
медленной сети при раскрытии «Прочее».
## Automation Candidates
- Компонентный вычислитель разреза (состав, ранжирование, тайбрейки, доли, границы 5/6/7,
формат долей) — чистая функция от Portfolio: детерминированные оракулы из сценариев,
максимум ценности на единицу поддержки.
- Интеграционные проверки таблицы исходов «Результат получения сектора и эмитента» и 24h-логики (управляемое время) —
регрессионно-критичны, зависят от классификации ошибок клиента.
- e2e smoke: вкладка есть/нет, reset/persist состояния, отсутствие навигации по строкам,
одно «Данные неполные».
## Regression Impact
| Область | Риск | Проверка |
| --- | --- | --- |
| Вкладка «Позиции» существующего счёта | Вкладки/состояния страницы изменены | TC-REG-001: прежний дефолт, состав и порядок вкладок |
| PortfolioStructure («Структура портфеля») | Общий выбор валюты «протекает» на вкладку | TC-CONC-015; существующий regression-прогон PortfolioStructure |
| GET-контракт Portfolio | Новые поля sector/issuer ломают клиентов; старый JSON | Существующий backend contract-прогон (OpenAPI); обратная совместимость ответа |
| Часовой обход hourly-investment-sync | Новые вызовы сектора/бренда меняют нагрузку и успех обхода | TC-REG-004: успех/причины обхода без sector/brand-сбоев; F07/F11 |
| Инвалидация token | Новое ветвление token-отказа | TC-META-002/005: 400 INVALID_TBANK_TOKEN во всех точках, замена/удаление подключения — 404/409 |
## Entry / Exit Criteria
Вход: G2 approved (есть); design готов или проверка не зависит от него (component-
вычислитель и integration по спекам можно начинать); тестовые данные раздела выше доступны.
Выход: все строки Coverage Matrix зелёные либо явные `not verified` ниже; все F01–F16
покрыты; регрессия TC-REG-001/004 и существующие contract/regression-прогоны пройдены; ручной блок (макеты, контраст, клавиатура,
скринридер) зафиксирован; findings по Q-D1/Q-D2/Q-F3 записаны.
## Gaps / Blockers
- not verified: соответствие квотам T-Bank при суточном обходе — риск деградации
синхронизации; закрывается Q-F3 (измерение на реальном токене), норма исходов от
результата не зависит.
- not verified (до реализации): фактический вид «не найдено» у `EtfBy`/`BondBy`/`GetAssetBy`
(Q-F3) — до подтверждения классификация NOT_FOUND в проверках задаётся моком по proto;
расхождение с реальностью выявит Q-F3-проверка.
- Открытый data-fact: Q-D1 (покрытие brand-пары) и Q-D2 (реальные `sector` фондов и
госбумаг) — одноразовые read-only проверки на реальном Portfolio владельца; влияют на
ожидаемый вид разрезов, не на нормы. Результат фиксируется в QA-отчёте change.
- Пробелов в Specs, блокирующих проверку, нет (после G2 approved).
@@ -0,0 +1,215 @@
# Product Research
## Problem Statement
[F] Change Owner просит рядом с вкладкой «Позиции» показать «Топ-5»: крупнейшие акции, облигации и секторы по денежной оценке, переключение критерия и distribution chart; отдельно спрашивает, какой ещё критерий полезен, и формулирует эстетическую цель «сделать что-то классное и красивое» [R0].
[F] Сейчас страница счёта уже содержит donut «Структура портфеля» по классам активов с выбором одной валюты, вкладки и внутри «Позиций» переключатель «Акции / Облигации / Прочее» [L1, L2, L3].
[I] Исследовательский вопрос: какой узкий набор критериев даст владельцу понятный ответ «где сконцентрированы деньги», не дублируя текущую структуру по классам активов и не смешивая несопоставимые валюты.
[I] Документ является входным контекстом для продуктового решения, а не набором требований, контрактов или проектных решений.
## Target Audience
[F] Известная аудитория изменения — один владелец WealthLedger, использующий личный инвестиционный трекер поверх T-Bank Invest API [R0].
[H] Предположение о более широкой аудитории, частоте использования аналитики, размере портфеля, числе валют и уровне финансовой грамотности отсутствует: интервью, аналитика и обращения пользователей не предоставлены.
[I] Поэтому внешние продукты используются как источник кандидатов и паттернов, но не как доказательство спроса владельца WealthLedger.
## Research Questions
[I] 1. Какие разрезы концентрации и аллокации действительно встречаются в публично описанных портфельных продуктах, а какие остаются непроверенными кандидатами?
[I] 2. Какие критерии можно вычислить из текущего Portfolio Snapshot, а для каких в нём нет данных даже при наличии полей в интеграции T-Bank?
[I] 3. Какие формы визуализации подходят для рейтинга и для долей целого, как представлять «Top-N + Прочее» и насколько полезны доля топ-5, HHI и эффективное число позиций частному инвестору?
[I] 4. Как сохранить канонический принцип «без валютной конвертации» при сравнении позиций и категорий?
[I] 5. Где разместить новый обзор относительно существующих «Позиций» и «Структуры портфеля», чтобы назначение экранов не дублировалось?
## Existing Solutions and Alternatives
| Вариант | Что делает | Сильные стороны | Слабые стороны | Источник |
| --- | --- | --- | --- | --- |
| Оставить как есть | [F] Полный список позиций по типам и отдельный donut по классам активов уже доступны [L1, L2, L3]. | [I] Базовая альтернатива без нового дублирования и без новых данных. | [I] Не даёт явного рейтинга топ-5 и секторного разреза из запроса владельца. | [F] [L1, L2, L3] |
| Т-Инвестиции: «Аналитика портфеля» | [F] Публичная справка описывает структуру по классам активов, секторам, валютам и компаниям; вкладка «Портфель» показывает доли компаний с акциями и облигациями одного эмитента вместе [E1]. | [I] Подтверждает ценность не только сектора, но и валюты и сквозной концентрации на эмитента. | [F] На проверенной странице не описаны топ-5, сроки погашения, кредитные рейтинги или HHI [E1]. | [F] [E1] |
| Т-Инвестиции: доходность и выплаты | [F] Справка описывает прирост стоимости, дивиденды и купоны, а также график будущих выплат и стоимость портфеля на графике [E2]. | [I] Показывает соседний, но отличный от текущей концентрации сценарий — вклад в результат и доход. | [I] Эти данные не доказывают необходимость смешивания дохода и стоимости в одном переключателе «Топ-5». | [F] [E2] |
| Portfolio Performance | [F] Statement of Assets содержит классификации, долю, стоимость, donut и treemap; документация называет asset class, sector, region, industry среди возможных классификаций, а Performance содержит показатели по бумагам и выплаты по инструментам [E6, E7, E8, E9]. | [I] Самый широкий проверенный набор разрезов и форм представления среди изученных источников. | [F] Taxonomy может требовать ручной классификации; публичная документация не доказывает, что все разрезы нужны данному владельцу [E7]. | [F] [E6–E9] |
| Intelinvest | [F] Публичная продуктовая страница заявляет структуру по странам, секторам, регионам, компаниям и классам активов, а также аналитический доход по годам и месяцам [E3]. | [I] Подтверждает распространённость географии, отраслей и дохода в российском портфельном продукте. | [I] Это маркетинговое описание; детали расчёта, топ-5, облигационные корзины и accessibility на странице не проверены. | [F] [E3] |
| Snowball Income | [F] Публичная страница описывает ребалансировку по секторам и типам активов с расхождением текущего и целевого распределения [E5]. | [I] Подтверждает сценарий сравнения структуры с целью, а не только наблюдения за текущими долями. | [I] Наличие топ-5, эмитентской концентрации, maturity/rating и HHI на проверенной странице не подтверждено. | [F] [E5] |
| Sharesight, Morningstar X-ray, Kubera, Yahoo Finance, Finam | [I] Эти продукты рассматривались как кандидаты для выборки, но их конкретные критерии в доступных публичных страницах в ходе исследования не подтверждены. | [I] Не используются как доказательство «стандарта рынка». | [I] Не проверено; любые утверждения об их текущих разрезах потребуют отдельной проверки первичных источников. | [I] Нет подтверждённого источника по критериям |
| Простейший новый вариант: ranked bar/list | [I] Пять строк с названием, суммой и долей, при необходимости шестая строка «Прочее». | [I] Наглядно сохраняет ранг, точные подписи и доступное текстовое представление; не зависит только от различения цветов. | [I] Менее «эффектный» вид и ограниченная компактность при длинных названиях. | [I] Интерпретация на основе [L4, E10] |
| Donut «Top-5 + Прочее» | [F] Существующий виджет уже использует DonutChart, проценты/суммы в tooltip и текстовую легенду [L3]. | [I] Знакомый паттерн для долей целого и меньший риск визуального разнобоя. | [I] При повторении для классов активов будет дублировать виджет над вкладками; малые сектора труднее сравнивать по углам, чем по длинам. | [I] [L3] |
| Treemap | [F] Portfolio Performance предоставляет treemap для иерархических классификаций активов [E6]. | [I] Компактно показывает много категорий и может выглядеть выразительно. | [I] Для пяти категорий избыточен, а малые прямоугольники затрудняют подписи и точное сравнение. | [I] [E6] |
| Горизонтальная stacked bar | [I] Одна полоса показывает весь выбранный портфель и сегменты «Top-5 + Прочее». | [I] Компактная замена donut с явной общей суммой. | [I] Подписи малых сегментов и сравнение категорий остаются слабым местом; без текстовой легенды цвет становится единственным кодом. | [I] [E10] |
## Evidence & Sources
[F] **Проверенное ядро разрезов.** Текущие позиции, классы активов, секторы, география/регионы и валюты подтверждены хотя бы одним первичным продуктовым источником; эмитентская агрегация акций и облигаций дополнительно явно описана Т-Инвестициями [E1, E3, E6, E7].
[I] **Не универсальный стандарт.** Maturity buckets, кредитный рейтинг/risk level, вклад позиции в доход, top gainers/losers и HHI нельзя называть обязательным общим стандартом по этой выборке: либо найден только смежный отчёт, либо точный критерий не проверен.
[F] **Позиции по стоимости.** `PortfolioPositionDto` уже содержит `type`, `quantity`, `currentValue`, `displayName`, `ticker` и `absoluteReturn`; отдельных `sector`, `country`, `issuer`, `maturityDate` и `rating` в DTO нет [L4].
[I] **Акции и облигации — высокая готовность данных.** Рейтинг по `currentValue` внутри типа и выбранной валюты опирается на текущий snapshot; ограничения отсутствующих, нулевых или отрицательных оценок должны быть видимы, как и в существующей структуре [L3, L4].
[F] **Сектор и страна.** В интеграционных типах и proto T-Bank есть `sector`, `countryOfRisk`/`country_of_risk` и `currency`; `sector` присутствует у Share и Bond, а справка Т-Инвестиций предупреждает, что для российских бумаг встречаются отрасли и государственные ценные бумаги, а для иностранных — только отрасли [L5, L6, E1].
[I] **Сектор — средняя уверенность.** Поле существует upstream, но текущий Portfolio DTO его не exposes; полнота значений по реальным позициям владельца и применимость категории «государственные ценные бумаги» к секторному обзору не подтверждены [L4, L5, E1].
[I] **Страна/регион — кандидат на потом.** `countryOfRisk` доступен в интеграционных данных, но это страна риска, а не обязательно страна листинга, регистрации или валюты; регион потребует дополнительного смыслового отображения, которое в источниках не проверено [L5, L6].
[F] **Валюта.** Каноническая spec фиксирует Portfolio «без валютной конвертации» и итоги по валютам; существующая «Структура портфеля» уже показывает одну выбранную валюту за раз [L3, L8].
[I] **Валютный выбор — наиболее согласованный вариант.** Пер-валютный топ сохраняет сопоставимость сумм и существующий UX; единый топ или единый donut валютной аллокации по нескольким валютам требует общего знаменателя, то есть отдельного решения о конвертации и выхода за текущий узкий scope.
[F] **Эмитент.** В текущем DTO нет стабильного идентификатора эмитента; `displayName`, `isin` и `ticker` идентифицируют позицию/инструмент, но не доказывают принадлежность акции и облигации одной компании [L4].
[F] **Источник идентичности эмитента вне Portfolio Snapshot.** Полное описание актива T-Bank `AssetFull.brand` содержит бренд (`uid`, `name`, `company`, `sector`); у Share, Bond и Etf есть `asset_uid`, связывающий инструмент с активом. Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа [L9].
[I] **Эмитентская агрегация — полезный кандидат, источник идентичности есть.** Паттерн подтверждён Т-Инвестициями [E1]; бренд актива T-Bank (`AssetFull.brand`) даёт кандидата на идентичность эмитента вне текущего Portfolio Snapshot, где её нет [L4, L9]. Группировка по совпадающему имени остаётся ненадёжной эвристикой. Полнота бренда по позициям владельца не проверена [H].
[F] **Погашение и риск облигаций.** `TbankBond` содержит опциональные `maturityDate` и `riskLevel`; proto также описывает `perpetual_flag`, а текущий DTO этих полей не содержит [L4, L5, L6].
[I] **Maturity buckets возможны только для облигаций и требуют отдельной трактовки бессрочных бумаг и отсутствующих дат; `riskLevel` является полем T-Bank, а не подтверждённым кредитным рейтингом агентства [L5, L6].**
[F] **Кредитный рейтинг.** В проверенных текущих DTO, интеграционных типах и proto поле кредитного рейтинга не найдено; это наблюдение ограничено проверенными файлами, а не доказательством отсутствия любого рейтинга во всех продуктах T-Bank [L4, L5, L6].
[F] **Доход и P&L.** `absoluteReturn` уже есть у позиции, но текущий DTO не содержит дивидендный/купонный вклад; Portfolio Performance документирует отдельные performance и payments reports, а Т-Инвестиции отдельно описывают доходность и будущие выплаты [L4, E2, E8, E9].
[I] **Top gainers/losers по `absoluteReturn` технически ближе к MVP, чем income contribution, но отвечает на вопрос «что дало результат», а не «где сейчас деньги»; готовая постатейная история выплат в этом исследовании не проверена.**
[F] **Доступность цвета.** WCAG 2.2 SC 1.4.1 требует, чтобы цвет не был единственным визуальным средством передачи информации или различения элементов [E10].
[I] **Текстовая альтернатива обязательна для смысла диаграммы.** Название, сумма и доля в списке/легенде полезны не только для accessibility, но и для точного сравнения малых категорий [E10, L3].
[F] **Концентрация как риск.** FINRA описывает концентрацию как зависимость результата портфеля от одной позиции или группы связанных позиций и отмечает, что разные классы активов сами по себе не устраняют такую зависимость [E11].
[F] **HHI.** US DOJ определяет Herfindahl–Hirschman Index как сумму квадратов долей участников рынка; это источник математической формулы, а не нормы для личного инвестиционного портфеля [E12].
[I] **Доля топ-5 понятнее HHI для первого экрана.** Она прямо отвечает «сколько денег приходится на пять крупнейших позиций»; HHI и обратная величина `1 / Σw²` могут служить «эффективным числом позиций», но не имеют проверенного здесь персонального порога действия и создают риск ложной точности.
[I] **«Top-N + Прочее» сохраняет 100% выбранной совокупности и сокращает хвост, но скрывает состав «Прочего»; поэтому агрегат не заменяет полный список позиций.**
[F] **[R0]** Формулировка Change Owner и AS-IS контекст: Issue https://git.personal.ksv741.ru/WealthLedger/main/issues/48 (30.10.2026).
[F] **[L1]** `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx` — вкладки страницы счёта, размещение `PortfolioStructure`, `PortfolioStructure.key`, переключатель типов позиций.
[F] **[L2]** `frontend/src/pages/account/model/positions.ts` — виды секций `stocks | bonds | other`, отображение доли и результата позиции.
[F] **[L3]** `frontend/src/widgets/portfolio-structure/ui/PortfolioStructure.view.tsx` — DonutChart, выбор валюты, tooltip, легенда, обработка неполных/отрицательных/неоценённых данных.
[F] **[L4]** `backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts` — поля позиции и структуры портфеля.
[F] **[L5]** `backend/src/integrations/tbank/tbank-market.types.ts` — `sector`, `countryOfRisk`, `currency`, `maturityDate`, `riskLevel` и другие поля инструментов.
[F] **[L6]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — описание полей Share, Bond и связанных инструментов T-Bank.
[F] **[L7]** `backend/src/modules/investments/instruments/application/public-instrument.ts` — использование instrument metadata в публичном каталоге акций/облигаций, включая `sector`, `maturityDate`, `riskLevel`.
[F] **[L8]** `specs/openspec/specs/portfolio/portfolio-view/spec.md` — Portfolio Snapshot одного счёта, отсутствие валютной конвертации и итоги по валютам.
[F] **[L9]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — `AssetFull.brand` (~:1051-1067), `Brand` (:1220-1229), `asset_uid` у Share (:776), Bond (:543), Etf (:657); `Asset` (:1075-1080) без бренда; `BrandData` (`common.proto:120`) — только данные логотипа.
[F] **[E1]** Т-Банк, «Аналитика инвестиционного портфеля»: https://www.tbank.ru/invest/help/educate/yield-analysis/about/folio-lookup/ — структура портфеля по классам активов, секторам, валютам и компаниям.
[F] **[E2]** Т-Банк, «Доходность портфеля и аналитика в Т-Инвестициях»: https://www.tbank.ru/invest/help/educate/yield-analysis/ — стоимость, прирост, дивиденды, купоны и будущие выплаты.
[F] **[E3]** Intelinvest, официальная продуктовая страница: https://intelinvest.ru/ — заявленные разрезы структуры и аналитический доход; маркетинговый источник.
[F] **[E5]** Snowball Income, «Возможности»: https://snowball-income.com/overview — заявленная ребалансировка по секторам и типам активов; маркетинговый источник.
[F] **[E6]** Portfolio Performance Manual, «Statement of Assets»: https://help.portfolio-performance.info/en/reference/view/reports/statement/ — таблица, доли, donut, treemap и классификации.
[F] **[E7]** Portfolio Performance Manual, «Classifying your assets»: https://help.portfolio-performance.info/en/getting-started/classify-assets/ — taxonomy, asset class, sector, region, industry и ручная классификация.
[F] **[E8]** Portfolio Performance Manual, «Securities»: https://help.portfolio-performance.info/en/reference/view/reports/performance/securities/ — стоимость, прирост, прибыль/убыток и доходы по бумагам.
[F] **[E9]** Portfolio Performance Manual, «Payments»: https://help.portfolio-performance.info/en/reference/view/reports/performance/payments/ — выплаты по инструментам и суммы по типам платежей.
[F] **[E10]** W3C WAI, «Understanding SC 1.4.1: Use of Color»: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html — цвет не должен быть единственным носителем смысла.
[F] **[E11]** FINRA, «Concentrate on Concentration Risk»: https://www.finra.org/investors/insights/concentration-risk — риск зависимости от позиции или связанных позиций.
[F] **[E12]** US Department of Justice, «Herfindahl-Hirschman Index»: https://www.justice.gov/atr/herfindahl-hirschman-index — формула HHI и её исходная предметная область.
## Assumptions
[H] Под «крупнейшими» владелец имеет в виду наибольшую положительную `currentValue`, а не количество бумаг, прибыль, вес от всех счетов или целевую долю.
[H] Топ-5 рассматривается внутри одного Brokerage Account и одной выбранной валюты; включение cash и других счетов запросом не подтверждено.
[H] «Прочее» означает сумму позиций вне первых пяти, а не существующий тип позиций `other`; это разные понятия, которые нельзя смешивать без пояснения [L2].
[H] Владелец прежде всего хочет обзор текущей концентрации, а не рекомендации по ребалансировке, прогноз дохода или оценку кредитного качества.
[H] Фактическая полнота `sector`, `countryOfRisk`, `maturityDate` и `riskLevel` по позициям владельца неизвестна; реальных snapshot-данных в исследовании нет.
[H] Предпочтение «классное и красивое» не проверено отдельно; выбор конкретной диаграммы остаётся эстетической гипотезой, а не подтверждённой потребностью.
## Risks
[I] **Смешение валют.** Общий рейтинг без конвертации сравнит несопоставимые денежные единицы; конвертация изменит смысл канонического portfolio-view и расширит решение за пределы отображения топ-5 [L8].
[I] **Дублирование.** Новый donut по классам активов повторит существующий `PortfolioStructure`; название «Структура» дополнительно сделает границу двух блоков неочевидной [L1, L3].
[I] **Ложная полнота секторов.** Отсутствующий sector, специальная категория госбумаг и неприменимость сектора к части типов могут исказить доли, если не показывать неизвестную категорию [E1, L4].
[I] **Ошибочная идентичность эмитента.** Объединение по названию или тикеру может разделить связанные бумаги или объединить разные инструменты; текущий snapshot не содержит issuer identity [L4]; кандидат на стабильную идентичность — `AssetFull.brand` [L9].
[I] **Смешение задач.** Стоимость, unrealized P&L, исторические выплаты и будущие купоны имеют разные знаменатели и временной смысл; один безымянный переключатель критериев может создать видимость сопоставимости [E2, E8, E9].
[I] **Перегрузка MVP.** Страны, эмитенты, сроки, риск, доход, HHI и несколько типов диаграмм одновременно увеличат число непроверенных решений без данных об их ценности для единственного владельца.
[I] **HHI без действия.** Перенос антимонопольных порогов HHI на личный портфель был бы необоснованным; даже корректное число не подсказывает владельцу, какая концентрация приемлема именно для него [E11, E12].
[I] **Color-only и скрытый хвост.** Диаграмма без подписей нарушит принцип WCAG 1.4.1, а «Прочее» без суммы/доли и доступа к полному списку скроет значимую часть риска [E10].
## Conclusions
[I] **Высокая уверенность:** непосредственно запрошенные топ-5 акций и облигаций по текущей стоимости — самый обоснованный MVP: данные уже есть, результат не дублирует класс активов и решает конкретную задачу концентрации [L4].
[I] **Средняя уверенность:** секторный «Top-5 + Прочее» соответствует и запросу, и проверенным продуктовым паттернам, но качество зависит от непроверенной полноты upstream-данных [E1, E3, E6, L5].
[I] **Высокая уверенность:** выбор одной валюты за раз — единственный найденный вариант, который одновременно сохраняет текущий UX и каноническое отсутствие конвертации; общий мультивалютный топ без нового правила конвертации некорректен [L3, L8].
[I] **Средняя уверенность:** для топ-позиций ranked horizontal bar/list информативнее donut; donut допустим для секторных долей, если рядом есть текстовые суммы и доли, а существующий donut по классам активов не повторяется [L3, E10].
[I] **Средняя уверенность:** доля топ-5 — полезная простая метрика концентрации; HHI и эффективное число позиций лучше отложить, поскольку персональная интерпретируемость и порог действия не подтверждены [E11, E12].
[I] **Средняя уверенность:** следующими кандидатами логичны эмитентская концентрация и страна риска, а не кредитный рейтинг: первые подтверждены внешними паттернами и частично данными, второй не найден в проверенном текущем источнике [E1, E3, L4–L6].
[I] **Низкая уверенность в широте «рыночного стандарта»:** выборка первичных источников небольшая, несколько названных продуктов не проверены, а маркетинговые страницы описывают заявления производителей, а не фактическое использование.
[I] **Размещение:** рабочее имя «Концентрация» точнее охватывает позиции и секторы, чем «Топ-5»; «Структура» хуже из-за существующего одноимённого по смыслу виджета, а отдельная вкладка рядом с «Позициями» соответствует исходному запросу владельца [R0, L1, L3].
## Recommendation
[I] **Сузить scope до одного обзора текущей концентрации в выбранной валюте, а не строить универсальную аналитику портфеля.** Уверенность: высокая для стоимостного топа, средняя для секторов, низкая для дополнительных разрезов.
[I] **Must — рекомендуемый узкий MVP:** топ-5 акций и топ-5 облигаций по `currentValue`; топ-5 секторов плюс «Прочее»; сумма и доля каждой строки; доля совокупного топ-5; выбор одной валюты; ranked bar/list с текстовыми подписями и одной distribution-визуализацией без повторения классов активов.
[I] **Условие уверенности для секторов:** если реальные данные окажутся слишком неполными, секторный критерий разумно перенести в later, не подменяя неизвестные значения догадками; сам стоимостной топ от этого не зависит [L4, L5].
[I] **Later:** страна риска; агрегация акций и облигаций одного эмитента (на момент research — после подтверждения issuer identity; позднее Change Owner перенёс в MVP, так как источник найден в `AssetFull.brand` [L9]); maturity buckets и `riskLevel` только для облигаций; top gainers/losers как отдельный смысловой режим; income contribution после подтверждения истории выплат; HHI/эффективное число позиций после проверки понятности владельцу.
[I] **No для этого изменения:** кредитный рейтинг как обещанный критерий; скрытая валютная конвертация; повтор donut по классам активов; treemap только ради визуального эффекта; одновременный запуск всех перечисленных критериев.
[I] **Размещение:** предпочтительна отдельная вкладка рядом с «Позициями» с рабочим названием «Концентрация»; решение изменится, если владелец хочет именно короткий рейтинг без секторов — тогда название «Топ-5» станет точнее.
[H] **Открытый вопрос 1:** секторы нужны только по акциям или по всем инструментам, для которых T-Bank возвращает sector, включая облигации и категорию государственных бумаг?
[H] **Открытый вопрос 2:** подтверждает ли владелец пер-валютный показ вместо единого рейтинга с конвертацией, и какая валюта должна выбираться первоначально?
[H] **Открытый вопрос 3:** входят ли в топ только позиции с известной положительной стоимостью и нужно ли отдельно показывать число неоценённых позиций?
[H] **Открытый вопрос 4:** достаточно ли пяти строк плюс «Прочее», или владельцу важно раскрывать хвост без возврата к полному списку?
[H] **Открытый вопрос 5:** новый блок должен оставаться отдельным от текущей «Структуры портфеля» или в перспективе заменить/поглотить её?
[H] **Открытый вопрос 6:** нужна ли сейчас только текущая концентрация, или владелец ожидает также top gainers/losers и вклад дивидендов/купонов?
[H] **Открытый вопрос 7:** какие секторы и категории реально присутствуют в snapshot владельца и какая доля стоимости остаётся без sector?
@@ -0,0 +1,210 @@
# Системный анализ
## Контекст и статус
Цель — передать в Portfolio сектор инструмента и бренд его актива, чтобы frontend строил четыре разреза концентрации одного счёта без конвертации валют. Основание: `specs/openspec/changes/account-top-positions/proposal.md:11-18,60-63`; Issue [main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). Рабочая ветка specs — `feature/48-account-top-positions`.
Документ приведён в соответствие с решениями владельца [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081) и [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). G2 по комплекту Specs, system-analysis, data-mapping и failure-model approved (`process-record.md`, Gate Log, последняя строка G2). Технологии, внутренние компоненты и форма хранения здесь не проектируются.
Обозначения. **PC** — `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-concentration/spec.md`; **PVΔ** — `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-view/spec.md`. Requirement указываются по названию заголовка: `PC «Название»`, `PVΔ «Название»`. Пути с `backend/`, `frontend/`, `docs/`, `specs/` отсчитываются от workspace WealthLedger.
## Покрытие требований (Requirement Coverage)
Покрыты все 32 Requirement: 28 в PC и 4 в PVΔ (2 MODIFIED, 2 ADDED). Заголовки в первом столбце приведены дословно (проверено сопоставлением с файлами). Требования «Последнее известное значение сектора и эмитента» и «Ошибки получения сектора и эмитента» из Specs удалены: их содержание вошло в PVΔ «Свежесть и fallback полного Portfolio Snapshot» и PVΔ «Результат получения сектора и эмитента».
| Requirement | Flow / контракт | Модуль |
| --- | --- | --- |
| PC `Вкладка «Концентрация» страницы счёта` | F3: только страница одного счёта; `/all` вкладки не получает; вкладка по умолчанию прежняя | Hub |
| PC `Вкладка в состояниях страницы` | F2 → F3: нет вкладки при загрузке, ошибке, недоступности и пустом Portfolio; есть при ≥1 позиции или денежном остатке; только остатки → пустой разрез | Hub; failure-model F12 |
| PC `Переключение разрезов` | F3: четыре разреза одного ответа, один видимый; T-Bank не вызывается | Hub |
| PC `Начальное состояние вкладки` | F3: разрез и валюта сохраняются, пока User на странице; сброс при новом заходе, переключении счёта и перезагрузке; на сервер не передаётся | Hub |
| PC `Состав разрезов` | F3: Секторы — STOCK/BOND/FUND, Эмитенты — STOCK/BOND; `issuer` у FUND приходит в API, разрез его отбрасывает | data-mapping: «Применимость по типу», I3 |
| PC `Названия элементов` | F2 → F3: displayName, подпись sector, `issuer.name`; группировка эмитента по `issuer.id` | data-mapping |
| PC `Название эмитента при разных названиях бренда` | F3: имя — `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавитному порядку PC «Порядок элементов с равной суммой»; выбор на клиенте | data-mapping: I8 |
| PC `Позиции, учитываемые в разрезе` | F3: только `currentValue` > 0 в выбранной валюте | data-mapping: I4 |
| PC `Сноска о не учтённых позициях` | F3: null в любой валюте и отрицательные в выбранной | data-mapping: I12 |
| PC `Одна валюта за раз` | F3: одна валюта на все разрезы, без конвертации | data-mapping: I11 |
| PC `Доступные валюты вкладки` | F3: ненулевые оценки STOCK/BOND/FUND, ISO-порядок; при одной валюте или без валют выбора нет | data-mapping: I11 |
| PC `Независимый выбор валюты вкладки` | F3: локальный выбор; пропавшая валюта → первая в списке | Hub |
| PC `Топ-5 и строка «Прочее»` | F3: до 6 элементов — все строки; от 7 — пять и «Прочее» с числом остальных | data-mapping: I7 |
| PC `Раскрытие строки «Прочее»` | F3: остаток уже входит в ответ; нового запроса нет | Hub |
| PC `Порядок элементов с равной суммой` | F3: название без регистра: цифры, латиница, кириллица, затем порядок в Portfolio | data-mapping: I8 |
| PC `Расчёт долей` | F3: знаменатель — сумма учитываемых позиций разреза | data-mapping: I5, I9 |
| PC `Показ долей` | F3: формат карточек позиций, «< 0,1 %» | data-mapping |
| PC `Суммарная доля топ-5` | F3: метрика только при ≥6 элементах | data-mapping: I7 |
| PC `Группы «Сектор не указан» и «Эмитент не указан»` | F2 → F3: `null` образует равноправный элемент | data-mapping: I6; failure-model F06 |
| PC `Подписи секторов` | F3: известный код → подпись, иначе код как получен | data-mapping |
| PC `Русские подписи известных секторов` | F3: таблица подписей | data-mapping |
| PC `Диаграмма распределения разреза` | F3: сегменты совпадают со списком; ≤6 элементов — по сегменту, ≥7 — пять и «Прочее» | data-mapping: I10 |
| PC `Центр диаграммы` | F3: сумма разреза и подпись «Топ-5 · N %» при показе метрики | data-mapping: I10 |
| PC `Строки без переходов` | F3: строки без переходов; в «Эмитентах» без логотипов; `logoName` остаётся в API | Hub |
| PC `Доступность диаграммы без цвета` | F3: текстовый эквивалент и сопоставление сегмента со строкой | Hub; `ux-design.md` |
| PC `Пустое состояние разреза` | F3: нет учтённых элементов — пустое состояние без доли и диаграммы | data-mapping: I14 |
| PC `Предупреждение о неполных данных` | F2 → F3: `meta` обозначает неполноту — «Данные неполные» только на вкладке над разрезом; страница его не дублирует; позиции без оценки и отсутствие sector/issuer предупреждения не вызывают | failure-model F14; заметка для design ниже |
| PC `Состояния Portfolio на вкладке «Концентрация»` | F2 → F3: состояния как на «Позициях»; отсутствие sector/issuer не скрывает вкладку и не меняет успешность и свежесть | failure-model F12, F14 |
| PVΔ `Нормализованный состав Portfolio` (MODIFIED) | F1 → F2: аддитивные `sector`, `issuer`; значения только у STOCK/BOND/FUND, включая `issuer` фонда; обрезка пробелов; остальные типы `null`; UID → FIGI; исключение — отказ token | Hub: контракт; data-mapping; failure-model |
| PVΔ `Свежесть и fallback полного Portfolio Snapshot` (MODIFIED) | F1, F2: два исключения — сектор и бренд хранятся как последнее известное значение (GET читает хранимое, T-Bank не вызывает); отказ token при получении обрабатывается как при обходе Portfolio | Hub; failure-model F08, F11 |
| PVΔ `Обновление сектора и эмитента` (ADDED) | F1: обновление при первом обходе, начавшемся не ранее чем через 24 ч после последнего успешного получения; пока значение не получено — при каждом обходе | failure-model: «Таймауты и повторы» |
| PVΔ `Результат получения сектора и эмитента` (ADDED) | F1: четыре исхода определяют поле и отсчёт 24 ч; неуспешное получение не мешает обновлению Portfolio | data-mapping; failure-model F02–F05 |
## Системный контекст и границы
Участники: T-Bank Invest API → backend часовой синхронизации → сохранённый Portfolio Snapshot → backend HTTP API → авторизованный frontend. T-Bank владеет исходной классификацией и брендами, WealthLedger — нормализацией и представлением. Подтверждено `docs/architecture/context.md:9-38`, `docs/architecture/containers.md:9-33`, `docs/integrations/tbank.md:3-21`.
Сектор и бренд получает сервер в часовом обходе, не браузер и не открытие вкладки. Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank/tbank-data-sync/spec.md:9-14`; PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Сектор и бренд читаются из хранимого значения»). MOEX, НКР и CDN логотипов не являются источниками sector/issuer.
**Владелец расчёта разрезов — frontend над полным ответом Portfolio.** Подтверждено: Change Profile — «вкладка считается по уже загруженному Portfolio» (`specs/openspec/changes/account-top-positions/process-record.md:16`), impact в `proposal.md:60`. Specs закрепляют клиентский расчёт: имя эмитента выбирается по позиции с наибольшим `currentValue` в выбранной валюте (PC «Название эмитента при разных названиях бренда»), а валюта и учитываемость зависят от выбора пользователя (PC «Позиции, учитываемые в разрезе», PC «Одна валюта за раз»). Серверный агрегат такой выбор не воспроизвёл бы без параметров запроса, которых контракт не вводит. Backend-агрегаты, endpoint топ-5 и обращение браузера к каталогу не требуются.
## As-Is / To-Be
### As-Is: фактический путь
1. Часовой планировщик независимо от экрана обновляет membership и Portfolio счетов (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:176-209`). Интервал и отсутствие перекрытий — `specs/openspec/specs/tbank/hourly-investment-sync/spec.md:8-21`.
2. Для счёта параллельно запрашиваются GetPortfolio и GetPositions; после нормализации полный Snapshot публикуется, и только затем запрашивается обновление metadata позиций (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`). Регистрация metadata-запроса не ждёт внешнего lookup (`backend/src/integrations/tbank/instrument-metadata.service.ts:63-105`).
3. Snapshot хранится одним JSON payload; публикация транзакционна и ограждена connection revision, attempt и доступностью счёта (`backend/prisma/schema.prisma:60-70`; `backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:116-147`).
4. Mapper считает `currentValue` из quantity × currentPrice и заполняет `logoName` из metadata (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31,63-99`). Это прецедент необязательного обогащения.
5. GET читает сохранённый Snapshot и дополняет позиции сохранённой metadata без обращения к T-Bank (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:55-105,122-164`); время финансового Snapshot при этом не меняется.
6. Общая metadata — GetInstrumentBy по UID, затем по FIGI. `Instrument` содержит `asset_uid`, но не `sector`; его `brand` — `BrandData` (логотип и цвета), а не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:939-998`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Локальный `TbankInstrument` не объявляет `assetUid` и `sector` (`backend/src/integrations/tbank/tbank.client.ts:91-99`).
7. Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; это отдельный источник, а не готовое обогащение Portfolio (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:335-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23`).
8. **Расхождения AS-IS с PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента», которые должна закрыть реализация.** (а) Общая metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`); норма требует срок от последнего успешного получения и четыре исхода. (б) Клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`); норма относит «не найдено» к исходу «значения нет». Это требования к реализации, а не выбор между вариантами.
9. Frontend берёт Portfolio из generated paths существующего GET (`frontend/src/entities/portfolio/api/usePortfolio.ts:1-28`). Текущие DTO и опубликованный snapshot новых полей не содержат (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`; `frontend/src/shared/api/spec/openapi.json:6426-6519`; `frontend/src/shared/api/generated/schema.d.ts:1059-1085`).
10. Общий справочник инструментов в AS-IS задокументирован как онлайн, без долговременного хранения (`docs/architecture/cross-cutting/data-freshness.md:9-12`, ADR 019); PVΔ «Свежесть и fallback полного Portfolio Snapshot» вводит исключение для сектора и бренда.
### To-Be: системные обязательства
- Каждая позиция содержит `sector` и `issuer`; значения только у STOCK, BOND, FUND, у остальных типов `null` (PVΔ «Нормализованный состав Portfolio»).
- Получение — в часовом обходе: при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе (PVΔ «Обновление сектора и эмитента»). Поле и отсчёт 24 часов после каждого получения определяет таблица исходов (PVΔ «Результат получения сектора и эмитента»); здесь она не пересказывается.
- Сектор и бренд хранятся как последнее известное значение; GET читает хранимое (PVΔ «Свежесть и fallback полного Portfolio Snapshot»).
- Отказ token при получении обрабатывается как отказ token в обходе Portfolio (PVΔ «Свежесть и fallback полного Portfolio Snapshot»); иные ошибки на обновление Portfolio не влияют (PVΔ «Результат получения сектора и эмитента»).
- Frontend получает полный список позиций и сам вычисляет разрезы; Portfolio не сортируется и не объединяется (PVΔ «Нормализованный состав Portfolio»).
Способ получения (типизированный запрос или ранее сохранённые данные), форма хранения времени успешного получения и порядок вызовов относительно публикации — design; нормы Specs от них не зависят, кроме того, что после отказа token подключение недействительно.
## Анализ влияния
| Область | Изменение / обязательство | Не меняется |
| --- | --- | --- |
| T-Bank → backend | Типизированный sector и asset → brand только для STOCK/BOND/FUND; источники-кандидаты в data-mapping | GetPortfolio/GetPositions, права подключения |
| Часовой обход | Дополнительные чтения по инструментам и активам по сроку PVΔ «Обновление сектора и эмитента» | Интервал 60 минут, запрет перекрытия |
| Сохранённый Snapshot и состояние получения | Новые nullable поля позиций; хранение последнего известного значения и времени последнего успешного получения (PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента») | Количество, деньги, порядок, `fetchedAt` финансового наблюдения |
| Backend → frontend | Аддитивные поля `PortfolioPositionDto` в `/api/v1` | Envelope, параметры, прежние статусы |
| Подключение | Отказ token при получении сектора/бренда делает подключение недействительным как при обходе Portfolio | Модель доступа |
| Frontend | Четыре вычисляемых разреза над полным Portfolio; единственное сообщение о неполноте на вкладке (PC «Предупреждение о неполных данных») | Сводка, остальные вкладки |
| Режим «Все счета» | Общая DTO получает те же поля; вкладки и расчёта нет | Агрегирование счетов |
| API artifacts | backend OpenAPI → переданный snapshot → generated types | Автономность репозиториев |
| Docs | Темы Portfolio и T-Bank, глоссарий, исключение из онлайн-справочника в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019 — по docs-impact | В этой задаче docs не редактируются |
Общая DTO для двух GET подтверждена `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31`. Подробности — [data-mapping](data-mapping.md) и [failure-model](failure-model.md).
### Заметка для design: дублирование предупреждения о неполноте
PC «Предупреждение о неполных данных» требует одно сообщение о неполноте: «Данные неполные» на вкладке, страница его не дублирует. В AS-IS страница уже показывает собственный Alert при неполноте:
- `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:77-79` — `<Alert role="status">{t("investment.partial")}</Alert>` при `props.partial`; признак вычисляется как `portfolio.meta?.completeness === "incomplete"` (`frontend/src/pages/account/ui/AccountPage/AccountPage.vm.ts:124,130`);
- Alert выводится над `<Tabs>` (`AccountPageLoaded.view.tsx:108-112`, неконтролируемый `defaultValue="positions"`), поэтому виден при любой вкладке, включая «Концентрация»;
- текст Alert — «Период загружен не полностью» (`frontend/src/shared/i18n/locales/ru.ts:423`), не «Данные неполные».
Следствие: при открытой вкладке «Концентрация» и неполном Portfolio страничный Alert не должен показываться (иначе на странице два сообщения). Содержание других вкладок не меняется (PC «Вкладка «Концентрация» страницы счёта»). Состояние активной вкладки сейчас внутри `Tabs` и странице недоступно; как это обеспечить — design.
## Системы и ответственность
- T-Bank: инструмент, его сектор, связь с активом, идентичность и название бренда; допустимость token.
- Backend sync: полный финансовый результат; получение sector/brand по PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента»; хранение последнего известного значения; обработка отказа token.
- Backend HTTP: проверка доступа, выдача Portfolio с новым shape, явные `null` для старых Snapshot.
- Frontend: вычисление разрезов, единый набор сумм для списка и диаграммы, выбор имени эмитента по PC «Название эмитента при разных названиях бренда», единственное предупреждение о неполноте.
- Change Owner: принимает остаточные факты данных (Q-D1, Q-D2, Q-F3) как результат проверки при реализации; решений по ним не требуется.
## Владение данными
| Данные | Источник истины | Пишут | Читают |
| --- | --- | --- | --- |
| quantity, price, cash | T-Bank финансовые ответы | Backend публикует Snapshot | GET, frontend |
| `sector` (STOCK/BOND/FUND) | `sector` Share/Bond/Etf в T-Bank, обрезанный по краям | Backend; хранимое последнее известное значение | Frontend: группирует по точному коду |
| `issuer` (STOCK/BOND/FUND) | `AssetFull.brand.uid`/`name` в T-Bank, обрезанные по краям | Backend; пара целиком либо `null` | Frontend: группирует по `id`, имя по PC «Название эмитента при разных названиях бренда» |
| Время последнего успешного получения sector/brand | Backend | Backend | Backend: условие PVΔ «Обновление сектора и эмитента» |
| lastSuccessfulAt, isStale, `meta` | Подтверждённое финансовое наблюдение и состояние ресурса | Backend sync / local read | Frontend |
| Разрезы, суммы, доли, топ-5, имя эмитента | Производные от одного ответа Portfolio | Frontend, только вычисляет | Список и диаграмма |
| Русские подписи секторов | Таблица PC «Русские подписи известных секторов» | Frontend | Пользователь |
## Потоки взаимодействий
**F1 — часовой обмен.** Backend проверяет подключение и доступные счета → GetPortfolio + GetPositions → полный финансовый Snapshot. Для позиций STOCK/BOND/FUND, у которых значение не получено либо наступил срок PVΔ «Обновление сектора и эмитента», запрашиваются сектор и бренд актива; результат применяется по PVΔ «Результат получения сектора и эмитента»; отказ token делает подключение недействительным (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). Прочие сбои получения на финансовый результат не влияют.
**F2 — локальная выдача.** Авторизованный GET проверяет membership и connection → читает Snapshot и хранимые значения → `{data, meta}`. Для старого JSON без новых ключей возвращаются явные `null`; GET не обращается к T-Bank (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). `data: null` — отсутствие полного снимка, а не пустая концентрация (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`).
**F3 — клиентская проекция.** Из полного Portfolio frontend определяет список валют, выбирает разрез, отбирает позиции, агрегирует, ранжирует, выбирает имя эмитента и рассчитывает доли. Переключение разрезов и раскрытие «Прочее» новых запросов не требуют.
Диаграммы в отдельных файлах не создаются: работа ограничена тремя Markdown-файлами; три потока выше передают границы без внутреннего устройства.
## Контракты
| Контракт | Producer → Consumer | Версия / artifact | Совместимость | Опубликованный источник |
| --- | --- | --- | --- | --- |
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio`, `data.positions[]` | Backend → frontend | API v1; `PortfolioPositionDto` + delta ниже | additive | `frontend/src/shared/api/spec/openapi.json:6426-6519` — предоставленная версия |
| GET `/api/v1/brokerage-accounts/all/portfolio`, общий shape позиции | Backend → существующие потребители | Тот же DTO; вкладка не применяется | additive | `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31` |
| Instrument / Asset lookup | T-Bank → backend | `tinkoff.public.invest.api.contract.v1`, локальный `instruments.proto` | Внешний контракт не меняется; расширяется используемая часть | Источники-кандидаты в data-mapping |
### Семантическая delta `PortfolioPositionDto`
| Поле | Присутствие | Тип / ограничение | Значение |
| --- | --- | --- | --- |
| `sector` | Обязательно в каждой позиции | string или null | Код T-Bank без перекодирования, с обрезанными краями; только у STOCK/BOND/FUND; иначе `null` |
| `issuer` | Обязательно в каждой позиции | object или null | `{id, name}` только у STOCK/BOND/FUND, включая FUND; иначе `null` |
| `issuer.id` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.uid` | Не `asset_uid` и не UID инструмента |
| `issuer.name` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.name` | Не `brand.company`, не название актива |
Пример позиции с данными: `{"sector":"financial","issuer":{"id":"brand-uid-example","name":"Сбер"}}`; идентификатор условный. Без данных: `{"sector":null,"issuer":null}`. Поля независимы. Значение из одних пробелов считается отсутствующим (PVΔ «Нормализованный состав Portfolio»).
Изменение добавляет только response-поля: запросы, Money, маршруты, статусы и версия API остаются. Текущий snapshot — OpenAPI **3.0.0** (`frontend/src/shared/api/spec/openapi.json:2`); синтаксис 3.1 молча не вводится. Таблица — семантическая delta, не полный машиночитаемый target OpenAPI. Протокол требует target-контракт в `analysis/contracts/` до реализации (`specs/openspec/schemas/wealth-ledger-sdd-lab/schema.yaml:383-393`); он не создан, так как работа ограничена тремя файлами. Для G2 нужен отдельный разрешённый шаг публикации и проверки target-контракта. После реализации backend-generated OpenAPI должен семантически совпасть с ним до G4.
### Публикация и получение API artifact
Backend генерирует и проверяет OpenAPI; потребителю явно передаётся новый artifact. Frontend обновляет versioned `src/shared/api/spec/openapi.json`, локально генерирует `src/shared/api/generated/schema.d.ts` и проверяет соответствие. Generated types вручную не правятся; frontend не читает backend checkout, runtime не требует Swagger. Основания: `specs/openspec/specs/backend-platform/api-contract-generation/spec.md:10-35`; `docs/architecture/http-api.md:5-22`; `frontend/.ai/conventions/data.md:15-34`.
## Семантика ошибок
Новых бизнес-ошибок HTTP из-за sector/issuer нет, кроме отказа token. Результат получения определяет PVΔ «Результат получения сектора и эмитента»; `200` с группами «не указан» не означает сбой Snapshot (PVΔ «Нормализованный состав Portfolio»; PC «Группы «Сектор не указан» и «Эмитент не указан»», PC «Состояния Portfolio на вкладке «Концентрация»»).
Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: `400 INVALID_TBANK_TOKEN` без fallback Snapshot (PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Отказ token при получении бренда»; `specs/openspec/specs/portfolio/portfolio-view/spec.md:113-115`). Это соответствует текущему клиенту, сводящему PERMISSION_DENIED и UNAUTHENTICATED к InvalidToken (`backend/src/integrations/tbank/tbank.client.ts:571-575`).
Прочие ошибки доступа: нет подключения — `409 TBANK_NOT_CONNECTED`, недоступный счёт — `404` (`docs/domains/portfolio/flows/portfolio-loading.md:24-35`).
## Совместимость и миграция
1. **Старый потребитель → новый backend:** аддитивные JSON-поля не меняют существующий shape; известный потребитель — generated paths (`frontend/src/entities/portfolio/api/usePortfolio.ts:7-16`).
2. **Новый backend → старый JSON Snapshot:** отсутствующие ключи выдаются как `null`; Snapshot не пересоздаётся, `lastSuccessfulAt` не меняется. Значения появляются после обхода, получившего их по PVΔ «Обновление сектора и эмитента». Текущий spread payload этого сам не делает (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:88-95`); механизм — design.
3. **Новый frontend → старый backend или Client Cached Snapshot:** отсутствие ключей трактуется как неизвестные значения. Использование новых полей возможно после получения provider-first artifact.
4. **Свежесть:** в обходах раньше срока значение не меняется (PVΔ «Обновление сектора и эмитента»); sector/brand не влияют на `isStale` (PVΔ «Нормализованный состав Portfolio»).
5. **`/all`:** общий DTO проверяется, расчёта концентрации нет. Ломающих изменений для существующих потребителей нет.
## Подробные модули
- [data-mapping.md](data-mapping.md): источники полей, типы позиций, нормализация, инварианты, риски классификации.
- [failure-model.md](failure-model.md): сценарии отказов со ссылками на нормы, лимиты, повторы, восстановление.
- Отдельные nfr и observability не выбраны: нагрузка и частичное обогащение рассматриваются в failure-model без выдуманных SLA (`process-record.md:16-17`).
- `ux-design.md` — представление вкладки; в этом комплекте не дублируется.
## Открытые вопросы
### Закрыто решениями владельца
| Вопрос | Решение | Где в Specs |
| --- | --- | --- |
| `issuer` у фондов | Заполняется у STOCK/BOND/FUND; разрез «Эмитенты» фонды отбрасывает ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) | PVΔ «Нормализованный состав Portfolio»; PC «Состав разрезов» |
| Имя при разных названиях одного `issuer.id` | Название позиции с наибольшим `currentValue`; при равных — по алфавиту (3081, [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) | PC «Название эмитента при разных названиях бренда» |
| Пробелы | Края обрезаются; значение из одних пробелов отсутствует (3081) | PVΔ «Нормализованный состав Portfolio» |
| Срок и поведение при ошибке | Раз в сутки от последнего успешного получения; исходы по таблице (3081, 3082) | PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента» |
| Отказ token | Как отказ token в обходе Portfolio; прочие ошибки не мешают обновлению Portfolio (3081, 3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Результат получения сектора и эмитента» |
| Хранение сектора и бренда вопреки «Свежесть и fallback» | Исключение для сектора и бренда (3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot» |
| «Не найдено» от T-Bank | Как «значения нет» (3082) | PVΔ «Результат получения сектора и эмитента» |
| Предупреждение о неполноте | Только на вкладке (3082) | PC «Предупреждение о неполных данных» |
### Остаётся неизвестным
Это факты данных T-Bank; они проверяются при реализации и решения владельца не требуют.
| ID | Что неизвестно | Чем проверяется |
| --- | --- | --- |
| Q-D1 | Реальное покрытие STOCK/BOND/FUND брендом актива; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | См. data-mapping: однократный read-only прогон на подключении владельца с агрегированной сводкой |
| Q-D2 | Какие значения `sector` фактически отдаются для фондов и государственных облигаций | См. data-mapping: тот же прогон, список различных значений со счётчиками |
| Q-F3 | Фактические квоты методов T-Bank, число различных инструментов и активов, вид ответа «не найдено» | См. failure-model: официальные лимиты Invest API, измерение числа вызовов в обходе, запрос с несуществующим `asset_uid` |
@@ -0,0 +1,135 @@
# Test Cases — conc (вкладка «Концентрация», разрезы, доли)
Cases пишутся только там, где нужны данные, шаги или oracle сверх Scenario;
остальные строки Coverage Matrix в `../qa-analysis.md` ссылаются на Scenarios
Delta Specs `portfolio-concentration` (PC) и `portfolio-view` (PVΔ).
### TC-CONC-002 Вкладка в состояниях страницы
- **Verifies:** `portfolio-concentration` — `Вкладка в состояниях страницы` / все 3 Scenario
- **Risk / Priority:** средний / high — вкладка не должна появляться при загрузке, ошибке и пустом Portfolio, но должна при Cash Balance
- **Preconditions:** тестовый пользователь с тремя счетами (см. данные); frontend с управляемым API-моком
- **Test Data:** счёт A — Portfolio пуст (нет позиций и остатков); счёт B — только Cash Balance 1 000 RUB; счёт C — одна акция; мок: GET `/portfolio/{C}` сначала pending, потом 500
- **Steps:**
1. Открыть счёт A → проверить набор вкладок.
2. Открыть счёт B → открыть вкладку «Концентрация».
3. Открыть счёт C при pending GET и при ответе 500.
- **Expected Results:** A — вкладки «Концентрация» нет. B — вкладка есть, разрез «Акции» в пустом состоянии («Акции», RUB не показан — валют нет). C — в обоих случаях вкладки нет.
- **Automation Candidate:** yes (component)
### TC-CONC-005 Сброс состояния при новом заходе и смене счёта
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` (норма + Scenario «Новый заход»)
- **Risk / Priority:** средний / high — ошибочный persist состояния между заходами даёт чужой разрез/валюту
- **Preconditions:** счёт с позициями в RUB и USD (доступные валюты: RUB, USD — первая RUB)
- **Test Data:** портфель из TC-CONC-007; на вкладке выбраны «Секторы» и USD
- **Steps:**
1. Открыть счёт, вкладку «Концентрация», выбрать «Секторы», валюту USD.
2. Перезагрузить страницу → открыть вкладку.
3. Уйти со страницы счёта (список счетов) и вернуться → открыть вкладку.
4. Переключить счёт на счёт C (одна акция) → открыть вкладку.
- **Expected Results:** после шагов 2–4: разрез «Акции», выбрана первая валюта списка (RUB для этого счёта; для счёта C — валюта его акции).
- **Automation Candidate:** yes (e2e)
### TC-CONC-006 Сохранение состояния при переключении вкладок страницы
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` («Пока User остаётся на странице… разрез и валюта SHALL сохраняться»)
- **Risk / Priority:** низкий / medium — поведение внутри одной сессии страницы
- **Preconditions:** как TC-CONC-005
- **Steps:**
1. Вкладка «Концентрация»: выбрать «Облигации», USD.
2. Переключиться на вкладку страницы «Позиции».
3. Вернуться на «Концентрацию».
- **Expected Results:** открыт «Облигации» в USD.
- **Automation Candidate:** yes (e2e; объединяется с TC-CONC-005 в один прогон)
### TC-CONC-007 Состав разрезов по таблице типов
- **Verifies:** `portfolio-concentration` — `Состав разрезов` (все ветви таблицы) + `Расчёт долей`
- **Risk / Priority:** высокий / high — фильтр типов и агрегация определяют все цифры вкладки
- **Preconditions:** вкладка открыта, валюта RUB
- **Test Data (RUB, один счёт):** S1 `STOCK` 400, sector `energy`, issuer `b1` «Сбер»; S2 `STOCK` 100, sector `energy`, issuer `b1` «Сбербанк»; B1 `BOND` 100, sector `energy`, issuer `b1` «Сбер»; F1 `FUND` 300, sector `financial`, issuer `b1`; C1 `CURRENCY` 50; O1 `OTHER` 10
- **Steps:**
1. Разрез «Акции» → суммы и доли.
2. Разрез «Облигации».
3. Разрез «Секторы».
4. Разрез «Эмитенты».
- **Expected Results (расчёт):**
- Акции: S1 400 ₽ «80 %», S2 100 ₽ «20 %» (база 500 = 400+100; 400/500 = 80 %, 100/500 = 20 %).
- Облигации: B1 100 ₽ «100 %».
- Секторы: «Энергетика» 600 ₽ (400+100+100) «66,7 %», «Финансы» 300 ₽ «33,3 %» (база 900; 600/900 = 66,666… % → «66,7 %», 300/900 = 33,333… % → «33,3 %»); C1 и O1 не входят.
- Эмитенты: «Сбер» 600 ₽ «100 %» (400+100+100; фонд F1 исключён; имя — по наибольшей позиции S1 400 > B1 100 → «Сбер»).
- **Automation Candidate:** yes (component, вычислитель разреза)
### TC-CONC-008 Цепочка названия элемента name → ticker → FIGI
- **Verifies:** `portfolio-concentration` — `Названия элементов` + `portfolio-view` — `Нормализованный состав Portfolio` (отображаемый идентификатор)
- **Risk / Priority:** средний / medium — пустое название ломает список
- **Test Data:** акция A с name «Газпром»; акция B без name, ticker `SBER`; акция C без name и ticker, FIGI `BBG000123456`; позиция без issuer в «Эмитентах»
- **Steps:**
1. Разрез «Акции»: прочитать названия трёх строк.
2. Разрез «Эмитенты» для позиции без issuer.
- **Expected Results:** строки названы «Газпром», `SBER`, `BBG000123456`; в «Эмитентах» — «Эмитент не указан».
- **Automation Candidate:** yes (component)
### TC-CONC-015 Независимость выбора валюты и fallback при исчезновении
- **Verifies:** `portfolio-concentration` — `Независимый выбор валюты вкладки` (2 Scenario)
- **Risk / Priority:** средний / high — общий Select протекает между виджетами; пропавшая валюта оставляет битое состояние
- **Preconditions:** позиции в RUB и USD; «Структура портфеля» на той же странице
- **Steps:**
1. В «Структуре портфеля» выбрать USD; на вкладке «Концентрация» выбрать RUB.
2. В «Структуре портфеля» сменить USD → RUB → USD.
3. Обновить Portfolio моком так, чтобы позиций в RUB не осталось (только USD-акции).
- **Expected Results:** после шага 2 на вкладке по-прежнему RUB. После шага 3 выбрана первая валюта нового списка — USD (единственная), разрез пересчитан по USD-позициям.
- **Automation Candidate:** yes (component + e2e)
### TC-CONC-016 Границы 5/6/7 элементов и счётчик «Прочее»
- **Verifies:** `portfolio-concentration` — `Топ-5 и строка «Прочее»`, `Суммарная доля топ-5`
- **Risk / Priority:** высокий / high — граничные значения и формулировка числа элементов
- **Preconditions:** возможность подмены портфеля (мок/API-фикстура)
- **Test Data (RUB, суммы):** набор-1: 400, 250, 150, 100, 50, 30, 20; набор-2: 300, 250, 200, 150, 60, 40; набор-3: 300, 250, 200, 150, 100; набор-4: одна акция 100; набор-5: 12 акций, из них 7 в «Прочее»
- **Steps:** для каждого набора открыть разрез «Акции», прочитать строки, счётчик «Прочее» и центр диаграммы.
- **Expected Results (расчёт):**
- Набор-1 (сумма 1 000): 5 строк + «Прочее» 50 ₽ (2 элемента) «5 %»; доли строк «40 %», «25 %», «15 %», «10 %», «5 %»; центр «Топ-5 · 95 %» и «1 000 ₽».
- Набор-2 (сумма 1 000): 6 строк без «Прочее», доли «30 %», «25 %», «20 %», «15 %», «6 %», «4 %»; центр «Топ-5 · 96 %».
- Набор-3 (сумма 1 000): 5 строк, доли «30 %», «25 %», «20 %», «15 %», «10 %», «Прочее» нет; центр без «Топ-5».
- Набор-4: одна строка «100 %», центр только сумма.
- Набор-5: счётчик «Прочее» — «7 элементов»; доли по фактическим суммам.
- **Automation Candidate:** yes (component)
### TC-CONC-020 Формат и округление показанных долей
- **Verifies:** `portfolio-concentration` — `Показ долей` (все ветви нормы)
- **Risk / Priority:** средний / high — владелец сверяет доли с суммами; неверный формат подрывает доверие
- **Preconditions:** разрез «Акции», RUB; управляемый портфель (мок/API-фикстура)
- **Test Data (RUB, суммы):** набор-A: 1 и 15; набор-B: 100 000 000 и 1; набор-C: 100, 100, 100
- **Steps:** для каждого набора прочитать показанные доли строк и их сумму.
- **Expected Results (расчёт):**
- Набор-A (база 16): точные доли 1/16 = 6,25 % и 15/16 = 93,75 %. Округление до десятых, середина вверх: «6,3 %» и «93,8 %». Сумма показанных 6,3 + 93,8 = 100,1 % ≠ 100 % — допустимо (компенсации нет).
- Набор-B (база 100 000 001): 100 000 000/100 000 001 = 99,999999 % → «100 %» (без «,0»); 1/100 000 001 = 0,000001 % < 0,1 % → «< 0,1 %».
- Набор-C: 100/300 = 33,333… % → у всех трёх «33,3 %» (сумма показанных 99,9 % — допустимо).
- **Automation Candidate:** yes (component)
### TC-CONC-024 Полный проход таблицы подписей секторов
- **Verifies:** `portfolio-concentration` — `Русские подписи известных секторов` (все 11 строк таблицы)
- **Risk / Priority:** низкий / medium — таблица — эталон, одна ошибка портит разрез
- **Test Data:** 11 позиций (BOND/STOCK/FUND) секторов `consumer`, `energy`, `financial`, `health_care`, `industrials`, `it`, `materials`, `real_estate`, `telecom`, `utilities`, `government` по 1 ₽ каждая — доли не важны, важны названия
- **Steps:** открыть «Секторы», сверить каждую строку с таблицей PC:374.
- **Expected Results:** подписи точно по таблице: Потребительский сектор, Энергетика, Финансы, Здравоохранение, Промышленность, Информационные технологии, Материалы, Недвижимость, Телекоммуникации, Коммунальные услуги, Государственный сектор.
- **Automation Candidate:** yes (component, table-driven)
### TC-CONC-027 Доступность диаграммы скринридером и без цвета
- **Verifies:** `portfolio-concentration` — `Доступность диаграммы без цвета`
- **Risk / Priority:** средний / high (a11y) — информация только цветом недопустима
- **Preconditions:** набор-2 из TC-CONC-016; VoiceOver (или NVDA); тема светлая и тёмная
- **Steps:**
1. Прочитать диаграмму скринридером в свёрнутом состоянии.
2. Раскрыть «Прочее» (набор-1) и прочитать снова.
3. Клавиатурой: Tab к переключателям разрезов и строкам, Enter на «Прочее».
4. Сравнить контраст сегментов и подписей в обеих темах с макетами ux-design v2.
- **Expected Results:** скринридер читает для каждого сегмента название, сумму и долю в порядке списка (для набор-2: шесть пар «название — N ₽ — N %»); раскрытие не меняет прочитанный состав (6 сегментов); Enter/Space раскрывают «Прочее», focus видим; контраст соответствует макетам.
- **Automation Candidate:** no (ручная/visual; текстовый эквивалент — component)
@@ -0,0 +1,68 @@
# Test Cases — meta (sector/issuer enrichment, Snapshot-инварианты)
Cases добавляют данные/порядок событий/oracle сверх Scenarios Delta Specs
`portfolio-view` (PVΔ). Требуют управляемое время обхода (час) и управляемые
ответы T-Bank (integration, configured HTTP tests, моки по proto).
### TC-META-002 Token-отказ при получении сектора/бренда не маскируется fallback
- **Verifies:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot` (исключение token) / Scenario «Отказ token при получении бренда»
- **Risk / Priority:** высокий / high — invalid token, замаскированный старым Snapshot, скрывает от владельца потерю доступа
- **Preconditions:** сохранённый полный Snapshot старше 60 минут; подключение с invalid token; GET выполняется после обхода
- **Test Data:** мок T-Bank: GetPortfolio/GetPositions — PERMISSION_DENIED; типизированный инструмент (sector) — UNAUTHENTICATED
- **Steps:**
1. Дождаться обхода (или вызвать принудительно в тесте).
2. GET Portfolio пользователем.
3. Повторить с отказом только на `GetAssetBy` (бренд), финансы успешны.
- **Expected Results:** шаг 2 — `400` Problem Details, code `INVALID_TBANK_TOKEN`, fallback Snapshot не возвращается. Шаг 3 — тот же `400`/`INVALID_TBANK_TOKEN`: отказ token на любом звене сектора/бренда приравнен к отказу обхода (требование «Свежесть и fallback полного Portfolio Snapshot»), хотя финансы могли быть получены.
- **Automation Candidate:** yes (integration)
### TC-META-003 Суточный цикл обновления сектора
- **Verifies:** `portfolio-view` — `Обновление сектора и эмитента` / Scenario «Обход через 24 часа» + 24h-отсчёт таблицы `Результат получения сектора и эмитента`
- **Risk / Priority:** высокий / high — ошибка отсчёта даёт вечное старое значение или вечный ре-fetch
- **Preconditions:** управляемое время обхода (шаг «час»); позиция P; T-Bank меняет сектор P на `financial` сразу после T0
- **Steps (линейная шкала):**
1. T0: обход — успех, `sector: energy` (отсчёт с T0).
2. T0+1h … T0+23h: обходы — обращений за сектором нет.
3. T0+24h: обход запрашивает сектор, T-Bank отвечает UNAVAILABLE.
4. T0+25h: обход запрашивает сектор (ошибка не сбросила отсчёт), T-Bank отвечает `financial`.
5. T0+26h … T0+48h: обходы.
6. T0+49h: обход.
- **Expected Results:**
- Шаги 2: `sector: energy`.
- Шаг 3: `sector: energy` (последнее известное), отсчёт не сброшен (идёт от T0) — поэтому шаг 4 снова обращается.
- Шаг 4: `sector: financial`, отсчёт с T0+25h.
- Шаги 5: `sector: financial`, обращений нет (T0+25h+24h = T0+49h).
- Шаг 6: первое обращение после успеха — сектор запрошен.
- **Automation Candidate:** yes (integration, управляемое время)
### TC-META-004 Четыре исхода получения: поле и отсчёт 24 часов
- **Verifies:** `portfolio-view` — `Результат получения сектора и эмитента` (каждая строка таблицы) / Scenarios «Ответ без значения заменяет известное значение», «"Не найдено" перезапускает отсчёт», «Ответ о другом инструменте», «Недоступность T-Bank сохраняет известное значение», «Значение ещё не известно»
- **Risk / Priority:** высокий / high — таблица — ядро нового контракта; классификация NOT_FOUND решает поведение
- **Preconditions:** 4 позиции P1–P4, у каждой известен `sector: energy` (успех в T0); управляемое время; моки по proto
- **Test Data:** P1 → ответ со значением `it`; P2 → NOT_FOUND (и вариант «пустой объект без sector» отдельным прогоном); P3 → ответ с чужим `asset_uid`/UID; P4 → UNAVAILABLE (timeout)
- **Steps:**
1. В T0+24h провести обход со всеми четырьмя моками.
2. GET Portfolio, прочитать `sector` P1–P4.
3. В T0+25h: P2 теперь отвечает значением `financial`; P3 и P4 — тоже успех `financial`.
4. Обход T0+25h; GET; прочитать `sector`.
5. Обход T0+48h: P1 и P2 — успех `financial`; P3 и P4 — `energy`.
- **Expected Results:**
- Шаг 2: P1 `it` (отсчёт сброшен на T0+24h), P2 `null` (сброшен на T0+24h), P3 `energy` (не сброшен, retry), P4 `energy` (не сброшен, retry).
- Шаг 4: P2 — по правилу `Обновление сектора и эмитента` значение появляется только при обходе ≥24 ч после «не найдено» (T0+24h), поэтому в T0+25h остаётся `null`; P3 и P4 — `financial` (получено, сброс отсчёта на T0+25h).
- Шаг 5: P1 — запрошен снова (отсчёт сброшен в T0+24h, T0+48h ≥ 24 ч), становится `financial`; P2 — запрошен (≥24 ч от «не найдено»), становится `financial`; P3 и P4 — не запрашиваются (последний успех T0+25h, T0+48h < T0+49h), `sector: financial` остаётся.
- **Automation Candidate:** yes (integration; P2-ветка — минимальное воспроизведение перезапуска отсчёта)
### TC-META-005 Смена и удаление подключения: sector/brand не протекают
- **Verifies:** `portfolio-view` — `Свежесть и fallback` («прежние личные datasets недоступны…»), failure-model F11
- **Risk / Priority:** высокий / high — утечка данных между подключениями
- **Preconditions:** пользователь, подключения K1 (счёт A, позиции с sector/brand) и K2 (счёт B)
- **Steps:**
1. Заменить K1 на новое подключение K1' (новый token, те же счёта): GET счёта A.
2. Удалить K1': GET счёта A.
3. Поздняя публикация обхода K1 (симуляция задержанной попытки) — проверить счёт A и B.
- **Expected Results:** шаг 1 — 404/409/400 по контракту замены, прежние datasets K1 недоступны; sector/brand позиции не выдаются до первого успешного обхода K1'. Шаг 2 — 404. Шаг 3 — публикация отклонена (`id`/`attemptId`/`running`), в Portfolio K2 и нового K1' данные K1 не появляются.
- **Automation Candidate:** yes (integration)
@@ -0,0 +1,28 @@
# Test Cases — reg (регрессия затронутых областей)
Проверки сохранения существующего поведения, изменённого рисками change.
### TC-REG-001 Существующая вкладка «Позиции» неизменна
- **Verifies:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта` («SHALL NOT менять вкладку по умолчанию и содержание остальных вкладок»); регрессионный baseline canonical `portfolio-view`
- **Risk / Priority:** высокий / high — новая вкладка не должна ломать существующий экран
- **Preconditions:** счёт с позициями и Cash Balance; вкладка «Позиции» открывается по умолчанию в canonical-поведении
- **Steps:**
1. Открыть страницу счёта: какая вкладка активна, состав и порядок вкладок.
2. Пройтись по каждой прежней вкладке: содержимое и сортировка списка позиций, валютная сводка.
3. Режим «Все счета»: проверить отсутствие «Концентрации» и целостность прежних вкладок.
- **Expected Results:** по умолчанию открыта прежняя вкладка («Позиции»); «Концентрация» — рядом, последней из новых; содержание остальных вкладок и «Всех счетов» идентично canonical (сравнение по эталонным данным).
- **Automation Candidate:** yes (e2e + существующий regression-прогон)
### TC-REG-004 Часовой обход: sector/brand не влияют на успех и нагрузку синхронизации
- **Verifies:** `hourly-investment-sync` (canonical) + PVΔ `Свежесть и fallback` / F07, F10, F14
- **Risk / Priority:** высокий / high — новые RPC-вызовы в обходе могут ломать или замедлять финансовую синхронизацию
- **Preconditions:** тестовое подключение; счёт из TC-CONC-007 (6 позиций, смешанные секторы/бренды); метрики `tbank.rpc.*`
- **Steps:**
1. Обход при полностью успешных ответах: события `tbank.rpc.*`, успех публикации, `lastSuccessfulAt`.
2. Обход с тотальным сбоем sector/brand (UNAVAILABLE на все инструментальные вызовы), финансы успешны.
3. Обход с RESOURCE_EXHAUSTED на `GetAssetBy` (после повторов клиента).
4. Обход, где финансовые ответы не получены, но sector/brand успешны.
- **Expected Results:** шаг 1 — Snapshot опубликован, `isStale: false`. Шаги 2–3 — Snapshot опубликован, успех/свежесть финансов не изменены («Результат получения сектора и эмитента»: неуспешное получение SHALL NOT мешать обновлению Portfolio), sector/brand — прежние известные. Шаг 4 — публикации нет, прежний Snapshot с `isStale: true` (F01: успех sector/brand не заменяет финансовый). Число новых RPC-вызовов за обход фиксируется как baseline для Q-F3.
- **Automation Candidate:** yes (integration)
@@ -0,0 +1,244 @@
# UX Design
Visual Package: **v2** (2026-10-30), локальные SVG в `../visuals/`, хеши в разделе «Materials». v1 (четыре макета без центральной суммы, предупреждения и без разрезов с одной валютой) заменён.
Базис существующих паттернов (код, не визуальные материалы): `frontend` @ `71126c91f0b30f4d65a313ddc7d7f86963340455`, Mantine / `@mantine/charts` 9.7.3, `docs/design/overview.md`. Storybook не запускался, истории прочитаны как исходники.
Поведение определяют Delta Specs `portfolio-concentration` (28 Requirements, с учётом решений владельца [#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) и `portfolio-view`. Документ ссылается на Requirements и не вводит нового поведения; всё, чего в Specs нет, вынесено в «Design-level items».
## User Goals
Единственный пользователь — владелец, который отвечает на вопрос «где сконцентрированы мои деньги» и сравнивает вклад бумаг, секторов и эмитентов. Цель владельца: «сделать что-то классное и красивое» без выхода за пределы дизайн-системы.
| Цель | Что должно быть видно за один взгляд |
| --- | --- |
| Понять степень концентрации | «Топ-5 · N %» и сумма разреза в центре кольца |
| Узнать, кто в топе | Ранжированный список с названием, суммой, долей и длиной полосы |
| Не потерять остальное | Строка «Прочее» с числом элементов и раскрытием |
| Доверять цифрам | Одна валюта, предупреждение «Данные неполные», сноска «Не учтено позиций: N», одинаковые значения в кольце и списке |
## User Flows
Требования: `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`, `Вкладка в состояниях страницы`, `Переключение разрезов`, `Начальное состояние вкладки`, `Одна валюта за раз`, `Доступные валюты вкладки`, `Раскрытие строки «Прочее»`, `Строки без переходов`, `Сноска о не учтённых позициях`, `Предупреждение о неполных данных`.
```mermaid
flowchart TD
A[Страница счёта, вкладка «Позиции»] -->|Клик / Enter на «Концентрация»| B[Разрез «Акции», первая валюта по ISO]
B --> C{Что делает User}
C -->|Выбирает другой разрез| D[Разрез заменён, валюта сохранена, «Прочее» свёрнуто]
C -->|Выбирает валюту, если валют две и больше| E[Пересчёт по позициям валюты, «Прочее» свёрнуто]
C -->|Нажимает «Прочее»| F[Остальные элементы раскрыты, топ-5 и кольцо без изменений]
F -->|Повторное нажатие| C
C -->|Читает предупреждение и сноску| G[«Данные неполные» над разрезом; «Не учтено позиций: N» под списком]
C -->|Переключает другую вкладку страницы| H[Разрез и валюта сохраняются]
H -->|Возврат на «Концентрация»| C
C -->|Уходит со страницы или переключает счёт| I[Состояние сброшено]
I -->|Новый заход| B
J[Режим «Все счета»] -.->|Вкладки нет| X((выход))
```
1. **Открыть вкладку.** Вход: «Концентрация» в `Tabs.List` справа от «Позиции». Выход: разрез «Акции», первая валюта, кольцо с центром, список.
2. **Переключить разрез.** Один шаг на `SegmentedControl`. Валюта остаётся, «Прочее» сворачивается.
3. **Переключить валюту.** `Select`; пересчитывается текущий разрез, «Прочее» сворачивается. При одной валюте выбора нет, шаг пропускается.
4. **Раскрыть «Прочее».** Нажатие на строку (мышь, касание, Enter/Space), повторное нажатие сворачивает.
5. **Прочитать предупреждение и сноску.** Оба — текст без действий.
6. **Вернуться с другой вкладки страницы.** Разрез и валюта прежние; **новый заход** (уход со страницы, смена счёта, перезагрузка) — снова «Акции» и первая валюта.
7. **Пустой разрез.** User остаётся на вкладке и переключает разрез или валюту (если выбор показан).
## Information Architecture and Screen Structure
Положение: `Tabs.Tab value="concentration"` между «Позиции» и «Деньги». Вкладка не меняет `defaultValue` (`positions`); панель с `keepMounted={false}`, как у остальных. Требование сохранять разрез и валюту при переключении вкладок означает, что выбор живёт выше панели; способ — в `design.md`.
Порядок внутри панели:
1. **Предупреждение «Данные неполные»** — только при неполноте Portfolio, над карточкой (M5, M6). Один блок для всех разрезов: остаётся на месте при переключении разреза.
2. **Карточка** (`Paper withBorder radius="lg"`): шапка, затем две колонки на десктопе / одна на мобильном.
3. **Шапка.** `SegmentedControl` «Акции / Облигации / Секторы / Эмитенты» (aria-label «Разрез концентрации»). Справа подпись «Валюта» и `Select` — **только если валют две и больше**; иначе шапка содержит один переключатель (M5, M6).
4. **Hero-блок** (подложка `blue.0`, градиент в белый, радиус `lg`): кольцо 220 px, толщина 32. **Центр:** вверху «Топ-5 · N %» (muted, только если элементов шесть и больше), под ним сумма разреза (bold). Без метрики — только сумма (M6). Под кольцом подпись «<Разрез> · <валюта>».
5. **Ранжированный список** (`<ol>`): номер, маркер, [логотип], название и подпись, справа сумма и доля, полоса доли.
6. **Строка «Прочее»** (при семи и более элементах): кнопка-disclosure с «N элементов», суммой, долей и шевроном. Раскрытие показывает остальные элементы вложенным списком (M2).
7. **Сноска** «Не учтено позиций: N» (только при N > 0).
Содержимое строки по разрезам:
| Разрез | Логотип | Подпись под названием |
| --- | --- | --- |
| Акции | `PositionLogo` 40 px, без ссылки | тикер или идентификатор позиции |
| Облигации | `PositionLogo` 40 px, без ссылки | ISIN |
| Секторы | нет | нет |
| Эмитенты | **нет** (`Строки без переходов`) | нет |
Логотип в «Секторах» не нужен: элемент объединяет позиции. Строка «Прочее» логотипа не имеет. Ни одна строка не ведёт на другую страницу; `PositionLogo` и название не получают `to`.
**Кодирование без цвета** (`Доступность диаграммы без цвета`, `Группы «Сектор не указан» и «Эмитент не указан»`):
| Признак | Кодирование |
| --- | --- |
| Ранг 1–6 | Цифра в строке и у сегмента (для сегментов ≥ 4 %) + монохромная синяя шкала `blue.9 → blue.0` с обводкой `blue.9` |
| «Прочее» | Серая точечная штриховка в маркере, сегменте и полосе, серая обводка; слово «Прочее» |
| «Сектор не указан» / «Эмитент не указан» | Диагональная штриховка поверх цвета ранга; название в тексте |
| Значение | Всегда число в тексте строки; полоса и кольцо его дублируют |
Шкала однотонная, потому что `grape`, `orange` и `green` в «Структуре портфеля» означают классы активов (`portfolioCategoryPresentation`). Длина полосы — доля от 100 % суммы разреза, шкала совпадает с кольцом.
**Центр кольца.** Внутренний диаметр 156 px. «Топ-5 · N %» — 14 px / 600, сумма — 22 px / 700; сумма точная, без округления (`Центр диаграммы`), поэтому при длинной записи размер ступенчато уменьшается до 14 px (22 → 18 → 14), без переноса и обрезки. Обе строки входят в текстовый эквивалент.
## Interaction Behavior
| Элемент | Поведение |
| --- | --- |
| Переключение разреза | Fade + `translateY(4px)` 220 ms `ease-out`, как смена валюты в `PortfolioStructure`; при `prefers-reduced-motion: reduce` анимации нет. Вращения сегментов нет (`isAnimationActive: false`). Фокус остаётся на переключателе. |
| Смена валюты | Та же анимация; «Прочее» сворачивается. Если выбранная валюта пропала после обновления Portfolio, выбирается первая (`Независимый выбор валюты вкладки`), без уведомления. Если валюта осталась одна, `Select` исчезает. |
| Строка «Прочее» | `<button>` с `aria-expanded` и `aria-controls`; зона — вся строка, высота ≥ 44 px. Шеврон поворачивается на 180°, список раскрывается ≤ 200 ms (при reduce-motion — мгновенно). Фокус остаётся на кнопке. Пять строк и кольцо не меняются. |
| Строки топ-5 и раскрытые элементы | Не интерактивны: курсор обычный, переходов нет (`Строки без переходов`). |
| Hover/focus | Hover на сегменте подсвечивает строку и показывает tooltip «название · сумма · доля» (вид `PortfolioTooltip`); hover на строке подсвечивает сегмент. Это подсветка, не действие; вся информация доступна в тексте. |
| Касание | Tooltip по касанию сегмента — дополнение; список читается без жестов. |
| Фокус | Стандартное кольцо Mantine, не скрывается (показан на «Прочее» в M2). |
| Предупреждение «Данные неполные» | Статический блок `Alert role="status"`, без кнопок; ручного обновления нет (`portfolio-view`). |
## UI States
Данные вкладки выводятся из уже загруженного Portfolio; собственных loading и error у вкладки нет. Состояния — только те, что есть в Specs: `Вкладка в состояниях страницы`, `Пустое состояние разреза`, `Предупреждение о неполных данных`, `Сноска о не учтённых позициях`, `Доступные валюты вкладки`, `Состояния Portfolio на вкладке «Концентрация»`.
| Экран / область | Loading | Empty | Error | Success | Partial / stale |
| --- | --- | --- | --- | --- | --- |
| Страница счёта (Portfolio) | Skeleton страницы (`AccountPageLoading`); **вкладки «Концентрация» нет** | Штатный пустой Portfolio: **вкладки нет** (`AccountPageEmpty`) | `StateView error` (`AccountPageError`); **вкладки нет** | — | **Stale:** `SourceSyncStatus` над вкладками, как для «Позиций»; разрезы по сохранённому снимку. **Partial:** вкладка есть, над разрезом «Данные неполные» (M5, M6), разрезы по доступным данным |
| Вкладка, разрез с данными | n/a | n/a | n/a | Центр, кольцо, список, сноска при N > 0 (M1–M3, M5, M6) | Сноска «Не учтено позиций: N». Позиции без оценки при полном Portfolio предупреждение «Данные неполные» не вызывают |
| Разрез без учитываемых элементов (в том числе у счёта только с денежными остатками) | n/a | Название разреза и валюта (если валюта есть) вместо кольца, списка, метрики; `Select` (если показывается) и сноска остаются (M4) | n/a | n/a | Сноска при N > 0 |
| Выбор валюты | n/a | Скрыт при одной валюте и при отсутствии валют | n/a | `Select` при двух и более | — |
| Метрика «Топ-5 · N %» | n/a | Скрыта при пяти и менее элементах (M6) | n/a | Показана при шести и более (M1, M2, M5) | — |
| «Прочее» | n/a | Строки нет при шести и менее элементах (M5) | n/a | Свёрнуто / раскрыто | — |
| Группы «не указан» | n/a | n/a | n/a | Обычный элемент с названием и штриховкой (M2, M5) | Не влияют на успешность и свежесть Portfolio |
Disabled, offline, «нет прав»: не применимо (действий записи нет); недоступный счёт обрабатывает страница, вкладка в этих состояниях не показывается.
## Validation Feedback
Пользовательского ввода, кроме выбора из закрытых списков (разрез, валюта), нет; валидации и ошибок ввода нет. Обратная связь на выбор — мгновенная перерисовка (см. Design-level items про озвучивание).
## Responsive Behavior
Опора на существующую границу `48em`, уже применяемую в `PortfolioStructure.module.css`.
| Ширина | Раскладка |
| --- | --- |
| ≥ 48em, макеты M1, M2, M5 (1280) | Шапка в одну строку. Две колонки: hero 296 px + список, `gap` 32 (`xl`). Предупреждение (если есть) на всю ширину над карточкой. |
| < 48em, макеты M3, M4, M6 (390) | Одна колонка: переключатель разрезов во всю ширину; при двух и более валютах ниже строка «Валюта + Select» (в M6 её нет); hero во всю ширину, кольцо 220 по центру; ниже список. Строка: логотип 40 (если положен), название и подпись слева, сумма и доля справа, полоса под ними. |
| 320 px (минимум) | Переключатель разрезов — в контейнере с `overflow-x: auto` (паттерн `positionFilter`), подписи не сжимаются; страница не скроллится по горизонтали. |
| Вкладки страницы на мобильном | Существующая `Tabs.List` (`flex-wrap: nowrap; overflow-x: auto`, класс `tabsList`) прокручивается по горизонтали. В макетах (M3, M4, M6) это показано так: вкладка «Операции» обрезана краем экрана, а не вмещена целиком, и справа 40 px градиентное затухание. Обрезанная вкладка — основной признак прокрутки; затухание — необязательное усиление (см. Design-level items). «Концентрация» — вторая вкладка и на 320 px видна целиком. |
| Длинные названия | Название переносится до двух строк; суммы `white-space: nowrap`, `font-variant-numeric: tabular-nums`. Отсутствие названия — fallback из `Названия элементов`. |
| Много элементов в «Прочее» | Вложенный список без собственной прокрутки. |
| Масштаб текста 200 % | Колонки схлопываются в одну; числа не усекаются; сумма в центре кольца уменьшается ступенчато, но не обрезается. |
Целевые зоны переключателя, `Select` и «Прочее» ≥ 40–44 px (WCAG 2.2 AA 2.5.8: ≥ 24 px).
## Accessibility
Ориентир: WCAG 2.2 AA. Основа — `Доступность диаграммы без цвета` и `Центр диаграммы`.
| Элемент | Роль / имя |
| --- | --- |
| Панель | `Tabs.Panel`, имя от вкладки; внутри `<section aria-labelledby>` с заголовком «Концентрация» (visually hidden или `Title order={2}`) |
| Предупреждение | `Alert role="status"` с текстом «Данные неполные», не `alert` (не прерывает) |
| Переключатель разрезов | `SegmentedControl` (radiogroup), aria-label «Разрез концентрации» |
| Валюта | `Select`, aria-label «Валюта концентрации» (отличается от «Валюта структуры портфеля»); при одной валюте элемента нет в DOM |
| Кольцо | `role="img"`, имя «Распределение: <разрез>, <валюта>» + `aria-describedby` на скрытый текст: центр («Топ-5 · 86,5 %, 2 235 310 ₽» либо только сумма) и затем «1. Сбербанк, 612 480 ₽, 27,4 %; … Прочее, …» в порядке списка. Сегменты в порядок фокуса не входят |
| Список | `<ol aria-label="<разрез>: топ элементов">`; каждый элемент читается как «название, сумма, доля» |
| Полоса, маркер, штриховка | `aria-hidden` (дублируют текст) |
| «Прочее» | `<button aria-expanded aria-controls>`, имя «Прочее, N элементов, <сумма>, <доля>» |
| Сноска | Обычный текст, не `alert` |
**Клавиатура.** Порядок фокуса: вкладки страницы → разрез → валюта (если показана) → «Прочее» (если есть) → остальной контент. Строки не фокусируются, потому что на них нет действий. Enter/Space на «Прочее» переключает раскрытие; фокус при раскрытии не уходит. Прокручиваемый ряд вкладок на мобильном остаётся доступен с клавиатуры стрелками (поведение `Tabs`).
**Не только цвет.** Ранг — цифра + светлота + обводка; «Прочее» — точечная штриховка + слово; «не указан» — диагональная штриховка + слово; предупреждение — текст «Данные неполные».
**Контраст (расчёт по токенам `@wealthledger/tokens`, светлая схема)**
| Пара | Коэффициент | Требование |
| --- | --- | --- |
| text `dark.7` на белом | 15,5 : 1 | 4,5 |
| text `dark.7` на `blue.0` hero | 14,3 : 1 | 4,5 |
| muted `gray.7` на белом / на `blue.0` hero | 8,2 : 1 / 7,5 : 1 | 4,5 |
| Обводка сегмента `blue.9` на белом | 10,4 : 1 | 3 (граница графики) |
| Заливка `blue.6` полосы на треке `gray.1` | 4,7 : 1 | 3 |
| Серый контур «Прочее» `gray.6` на белом | 3,3 : 1 | 3 |
| Светлые заливки рангов 4–6 (`blue.3/1/0`) на белом | 1,8 / 1,2 / 1,1 : 1 | Информации не несут: различимы обводкой `blue.9` и цифрой |
| `gray.6` для мелкого текста | 3,3 : 1 | Не использовать; `muted` в теме — `gray.7` |
**Тёмная тема.** `docs/design/overview.md`: «тёмная тема не заявлена», токены описывают только светлую схему; контраст для тёмной не рассчитывался. Цвета в реализации берутся из токенов / CSS-переменных; литералы в SVG нужны только для самодостаточности.
**Движение.** `prefers-reduced-motion: reduce` отключает fade, раскрытие и переходы подсветки.
**Увеличение текста.** До 200 % без потери информации.
## Design System Reuse
| Часть | Решение | Обоснование |
| --- | --- | --- |
| Вкладка | **Reuse** `Tabs.Tab` страницы счёта | Консистентная навигация |
| Прокрутка вкладок на мобильном | **Reuse** `tabsList` (`overflow-x: auto`) | Уже работает; затухание у правого края в макете — необязательное улучшение |
| Переключатель разрезов | **Reuse** `SegmentedControl` + обёртка с `overflow-x`, как у «Акции / Облигации / Прочее» | Тот же паттерн внутри вкладки |
| Выбор валюты | **Reuse** `Select` (`allowDeselect={false}`, `w=112`, aria-label) из `PortfolioStructure`; скрывается при ≤ 1 валюте, как там | Единое поведение и вид |
| Кольцо | **Reuse** `DonutChart` (size 220, thickness 32, `startAngle 90 / endAngle -270`, без анимации, tooltip по сегменту) | Знакомая форма |
| Tooltip | **Reuse** `PortfolioTooltip` (`Paper`, `Text fw=600`) | Тот же вид |
| Логотипы | **Reuse** `PositionLogo` (Avatar 40, `radius md`, fallback — первая буква) без `to` | Совпадает с `PositionCard`; переходов по Specs нет |
| Предупреждение | **Reuse** `Alert role="status"` (как `investment.partial` страницы) | Тот же вид неполных данных |
| Состояния страницы | **Reuse** `StateView`, `SourceSyncStatus`, `LoadingSkeleton` | Наследуются без изменений |
| Анимация смены | **Reuse** keyframes `currency-change` | Единый ритм |
| Центр кольца из двух строк | **Новый** («Топ-5 · N %» + сумма вместо одной `chartLabel`) | Требует `Центр диаграммы`; реализация — в `design.md` |
| Строка рейтинга с полосой | **Новый** (номер, маркер, название, сумма/доля, полоса) | Нет компонента «строка доли»; `EntryCard` и `PositionCard` выражают карточку операции/позиции. Собирается из `Group`, `Text` и полосы |
| Шкала рангов | **Новый** (монохромные `blue.9…0`) | Не смешивать с цветами классов активов; только палитра токенов |
| Штриховки | **Новый** вспомогательный признак | `Доступность диаграммы без цвета`; название остаётся главным признаком |
| Disclosure «Прочее» | **Новый** по структуре (`UnstyledButton` + `Collapse`) | В `@wealthledger/ui` аналога нет; поведение задано Specs |
| Видимая подпись «Валюта» рядом с `Select` | **Отклонение** | В `PortfolioStructure` только aria-label; подпись поясняет значение на мобильном, aria-label сохраняется. Можно снять без последствий для Specs |
| Подпись «<Разрез> · <валюта>» под кольцом | **Новый** текст | Помогает при одной валюте, когда `Select` скрыт; значений не добавляет |
Новые токены не вводятся; в `@wealthledger/ui` ничего не добавляется, предметные компоненты остаются в приложении (`docs/design/overview.md`).
## Materials
Относительные пути заданы от каталога этого файла. SVG самодостаточны (без scripts, handlers, внешних шрифтов и ресурсов; `font-family: system-ui, sans-serif`), содержат `<title>` и `<desc>` с текстовым эквивалентом, включая центр кольца. Данные синтетические; цвета в SVG — литералы токенов для самодостаточности.
PNG-превью не создавались: единственный доступный рендерер (`qlmanage`) выдаёт квадратные миниатюры с полями, непригодные как эталон.
| ID материала | Относительный путь / ссылка | Экран/состояние и назначение | SHA-256 |
| --- | --- | --- | --- |
| M1 | `../visuals/desktop-stocks.svg` | Desktop 1280: «Акции», RUB, две валюты (выбор виден), 8 элементов: центр «Топ-5 · 86,5 %» + сумма, топ-5 с логотипами, «Прочее» свёрнуто, сноска «Не учтено позиций: 2» | `c4ef8f814f6b168d3d96b6b8288ee6c574b4cf361de451a2152b1e51c939dae5` |
| M2 | `../visuals/desktop-sectors-expanded.svg` | Desktop 1280: «Секторы», «Прочее» раскрыто и в фокусе, «Сектор не указан» со штриховкой на 5-м месте, без логотипов, центр «Топ-5 · 92,1 %» + сумма | `b59256bbcca8a414af85b487966dbda314ca30bbe20c5a65cf313096b4305106` |
| M3 | `../visuals/mobile-stocks.svg` | Mobile 390: «Акции», одна колонка, выбор валюты в отдельной строке, прокручиваемый ряд вкладок (обрезанная «Операции» + затухание) | `d0ed332a78bdf8c9646a3033db63fc45643b4f365b0fabf34f2433b815663dbd` |
| M4 | `../visuals/mobile-empty-slice.svg` | Mobile 390: пустое состояние «Облигации» в RUB (валют две, выбор виден), сноска «Не учтено позиций: 1» | `04f8be877948c3a46f0cea64558c5da1a3577a3a4b947f32b7341f83eda0a377` |
| M5 | `../visuals/desktop-issuers-warning.svg` | Desktop 1280: «Эмитенты», одна валюта (выбора нет), предупреждение «Данные неполные», шесть элементов без «Прочее», метрика «Топ-5 · 94,2 %» + сумма, без логотипов, «Эмитент не указан» со штриховкой | `ece4fa5499cd5c326569e3a3b3069546dc66964b9972cac65140449206fcb4ae` |
| M6 | `../visuals/mobile-issuers-warning.svg` | Mobile 390: «Эмитенты», пять элементов: в центре только сумма (метрики нет), предупреждение «Данные неполные», без выбора валюты и без логотипов | `1380c45d3db93c9b778a77c210f4444a19fe6666dff8b32080312584b78719ae` |
Что проверено: валидный XML (`xmllint`), отсутствие scripts, `href`, `<image>` и `@font-face`, оценка наложений текста, расчёт контраста по токенам. Макеты не просматривались на отрисованном изображении. Единственное «выходит за экран» по оценке — «Операции» в M3, M4, M6, и это намеренная обрезка `clipPath`. Макеты — иллюстрация компоновки, а не пиксельная спецификация.
## Закрытые findings для Specs
Решения владельца ([#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) внесены в Specs; UX-часть приведена в соответствие.
| ID | Решение | Requirement | Где отражено |
| --- | --- | --- | --- |
| S1 | Разрез и валюта сохраняются, пока User на странице, включая переключение вкладок; сбрасываются при новом заходе | `Начальное состояние вкладки` | User Flows 6, IA |
| S2 | `Select` скрыт при одной валюте и без валют | `Доступные валюты вкладки` | IA 3, UI States, M5, M6 |
| S3 | Метрика «доля топ-5» скрыта при пяти и менее элементах | `Суммарная доля топ-5` | IA 4, UI States, M6 |
| S4 | Неполный Portfolio: вкладка есть, над разрезом «Данные неполные» | `Предупреждение о неполных данных` | IA 1, UI States, M5, M6 |
| S5 | Вкладки нет при загрузке, ошибке и штатном пустом Portfolio | `Вкладка в состояниях страницы` | UI States |
| S6 | Неизвестный сектор показывается как получено, без пояснений | `Подписи секторов` | Без UX-обработки; текст читается скринридером как есть |
| S7 | «Прочее» показывает число остальных: «2 элемента», «5 элементов» | `Топ-5 и строка «Прочее»` | IA 6, M1, M2 |
| S8 (часть) | Центр кольца: «Топ-5 · N %» и сумма разреза | `Центр диаграммы` | IA 4, Accessibility |
| S9 | Без логотипов в «Эмитентах» и без переходов из строк | `Строки без переходов` | IA, Interaction Behavior, Design System Reuse |
Ранее предложенные вопросы Q1 (сумма разреза), Q3 (логотип эмитента), Q4 (переход из строки) закрыты теми же Requirements; Q8 (масштаб полосы от 100 %) принят как дизайн-решение.
## Design-level items
Поведения не меняют; остаются на уровне дизайна, подтверждаются в `design.md` / на ревью макетов. Пункты D1–D4 перенесены из прежних вопросов по просьбе владельца, D5–D7 найдены при обновлении.
| ID | Вопрос | Предложение по умолчанию |
| --- | --- | --- |
| D1 | Тексты пустого состояния. Specs задают содержание (название разреза и валюта, если есть), но не формулировку | Заголовок «<Разрез> в <валюта>: нет позиций» (без валюты, если валют нет); пояснение «Нет позиций с положительной оценкой в этом разрезе и выбранной валюте.» |
| D2 | Озвучивание смены разреза и валюты (бывший S8) | Одна `role="status"` (polite) строка: «Разрез «Секторы», RUB: 8 элементов»; при первом показе не объявляется |
| D3 | Минимальная видимая дуга для очень малых долей | Минимума нет (доли честные); сегмент опознаётся по списку и обводке |
| D4 | Нумерация внутри раскрытого «Прочее» | Продолжение ранга (6, 7, …), как в M2 |
| D5 | Сообщения о неполноте: на вкладке «Концентрация» страничное «Период загружен не полностью» не показывается, остаётся только «Данные неполные» на вкладке | Закрыт: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082); реализация — design.md F4 |
| D6 | Затухание у правого края мобильных вкладок отсутствует в коде; ряд уже прокручивается и обрезанная вкладка сама показывает прокрутку | Затухание не обязательно; если принимается, оно общее для всех вкладок страницы, а не только для «Концентрации» |
| D7 | Реализация двухстрочного центра `DonutChart` (`children` либо наложенный `Text`) и ступенчатое уменьшение суммы | Решает `design.md`; UX требует две строки, точную сумму без обрезки и текстовый эквивалент |
@@ -0,0 +1,319 @@
# Engineering Design
Design описывает, как backend и frontend реализуют утверждённые Specs и модули analysis для change `account-top-positions`. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](analysis/system-analysis.md); Requirement указываются по названию заголовка. Пути с `backend/` и `frontend/` отсчитываются от workspace; база кода — backend `d37fa2c`, frontend `71126c9`. Design и [ux-design.md](analysis/ux-design.md) образуют один Design package с одним approval на G3. UX здесь не пересказывается: этот документ описывает только инженерную реализацию.
## Inputs
- Specs (G2 approved): `specs/portfolio/portfolio-concentration/spec.md` (28 Requirement), `specs/portfolio/portfolio-view/spec.md` (PVΔ «Нормализованный состав Portfolio», PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента»).
- Analysis (G2 approved): [system-analysis.md](analysis/system-analysis.md) (контракты, семантическая delta `PortfolioPositionDto`, заметка о дублировании Alert), [data-mapping.md](analysis/data-mapping.md) (M1–M3, источники-кандидаты в T-Bank, инварианты I1–I8), [failure-model.md](analysis/failure-model.md) (классификация исходов, F01–F16).
- Design package (G3): [ux-design.md](analysis/ux-design.md), Visual Package **v2** (`visuals/*.svg`, SHA-256 — в ux-design «Materials»).
- Параллельно с design: [qa-analysis.md](analysis/qa-analysis.md) и `analysis/test-cases/` (oracle для расчётов).
- Ограничения: `backend/AGENTS.md` (integrations, data, http-api conventions), `frontend/AGENTS.md` (`.ai/architecture.md`, `.ai/conventions/ui.md`, `.ai/conventions/data.md`), ADR 015, 017, 019 (`docs/adr/`).
- `analysis/contracts/` нет. Target-контракт — таблица «Семантическая delta `PortfolioPositionDto`» в system-analysis.
## Architecture Drivers
| ID | Driver | Источник |
| --- | --- | --- |
| D1 | Сектор и бренд хранятся как последнее известное значение. Обновление — при первом обходе, начавшемся не ранее чем через 24 ч после последнего **успешного** получения; пока значение не получено — при каждом обходе. Сектор и бренд независимы | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента» |
| D2 | Четыре исхода получения: заменить, `null`, сохранить прежнее; отсчёт сбрасывается или не меняется | PVΔ «Результат получения сектора и эмитента», failure-model «Классификация» |
| D3 | Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: fallback не возвращается, `400 INVALID_TBANK_TOKEN` | PVΔ «Свежесть и fallback полного Portfolio Snapshot», failure-model F08 |
| D4 | Прочие отказы не мешают публикации Snapshot и не меняют `isStale` или `lastSuccessfulAt` | PVΔ «Нормализованный состав Portfolio», PVΔ «Результат получения сектора и эмитента», F02–F12 |
| D5 | GET не обращается к T-Bank | PVΔ «Свежесть и fallback полного Portfolio Snapshot»; ADR 019 |
| D6 | Квоты T-Bank: каждый RPC проходит через общий RateLimiter (`TBANK_RATE_LIMIT_PER_SECOND`, по умолчанию 5/с) и bounded queue на 50 элементов | `backend/src/integrations/tbank/tbank.client.ts:147-190,52`; integrations convention; Q-F3 |
| D7 | Аддитивный DTO: `sector`, `issuer{id,name}` присутствуют всегда и nullable; в старых Snapshot — `null` | system-analysis «Семантическая delta», «Совместимость» |
| D8 | Точные суммы и доли (в сумме ровно 100 %), округление до десятых с серединой вверх, «< 0,1 %» | PC «Расчёт долей», PC «Показ долей», PC «Суммарная доля топ-5» |
| D9 | Разрез и валюта сохраняются при переключении вкладок страницы и сбрасываются при новом заходе или смене счёта | PC «Начальное состояние вкладки» |
| D10 | На вкладке «Концентрация» одно сообщение о неполноте; страница его не дублирует | PC «Предупреждение о неполных данных»; system-analysis «Заметка для design» |
| D11 | Информация диаграммы передаётся не только цветом; есть текстовый эквивалент; в центре две строки | PC «Диаграмма распределения разреза», PC «Центр диаграммы», PC «Доступность диаграммы без цвета»; ux-design «Accessibility», D7 |
| D12 | FSD-границы, вычисление в VM без effect, generated types берутся только из предоставленного snapshot | `frontend/.ai/architecture.md`, `ui.md`, `data.md` |
## Technical Goals / Non-Goals
**Goals:**
- получать сектор и бренд в существующем часовом шаге Portfolio без нового фонового процесса;
- хранить их с возрастом каждого поля отдельно;
- добавить два DTO-поля;
- считать разрезы на клиенте чистой функцией с точной десятичной арифметикой.
**Non-Goals:**
- менять общую metadata инструментов (`InstrumentMetadataService`) и её семантику свежести;
- переписывать сохранённые Snapshot;
- конвертировать валюты;
- добавлять новые библиотеки, очереди, таймеры или процессы;
- исправлять `formatShare` в карточке позиции (см. Risks);
- менять каталог акций и облигаций.
## Implementation Impact Map
| Область | Затронута | Результат или причина N/A |
| --- | --- | --- |
| main | нет | Root содержит только workspace-навигацию; кода и контрактов там нет |
| backend | да | Prisma-модель и аддитивная миграция; 3 метода `TbankClient` и opt-in обработка NOT_FOUND; `PositionClassificationService`; шаг в `PortfolioService.queueSnapshot` до publish; параметр начала обхода из `TbankHourlyScheduler`; DTO `sector`/`issuer`; нормализация старых Snapshot в `saved()` |
| frontend | да | Новый openapi snapshot и `schema.d.ts`; `pages/account/model/concentration.ts`; state вкладки, разреза и валюты в `AccountPage.vm.ts`; управляемые `Tabs` и подавление Alert в `AccountPageLoaded`; view `Concentration/`; ключ `stock.sectors.government` в `ru.ts` |
| specs | да | Этот `design.md`. Specs не меняются; findings — в разделе Risks. Contract Parity на G4 сравнивает generated OpenAPI с delta из system-analysis |
| docs | да | Объём определяет docs-impact. Инженерные затронутые места: исключение из «справочник онлайн» в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019, домен Portfolio (поля позиции, вкладка), страница интеграции T-Bank (ShareBy/BondBy/EtfBy/GetAssetBy), глоссарий |
## Decisions
### Backend
**B1. Отдельная таблица классификации, а не общий metadata-кэш** (D1, D2, D4)
- **Контекст.** Общий кэш хранит один JSON `TbankInstrument.metadata` из GetInstrumentBy. Он считает `fresh = complete` без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:295-323`). `Instrument` не содержит `sector`, а его `brand` — это `BrandData`, а не `Brand{uid,name}` (data-mapping «Что уже есть в коде»). Запрос metadata запускается асинхронно после publish и при каждой ошибке перезаписывает или отбрасывает весь payload (`instrument-metadata.service.ts:111-222`).
- **Выбрано.** Новая глобальная (не персональная) таблица `TbankInstrumentClassification`. Состав — в разделе Data / Storage. Возраст сектора и бренда хранится отдельно: `sectorFetchedAt`, `brandFetchedAt`.
- **Отвергнуто:** расширение metadata JSON и `selectMetadata`; переиспользование `TbankInstrumentSource`; хранение в payload Snapshot. Причины — в Solution Assessment.
**B2. Получение — синхронный шаг attempt Portfolio до publish** (D3, D4, D5)
Шаг встраивается в `queueSnapshot` (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:262-283`):
1. GetPortfolio и GetPositions.
2. `normalize`.
3. **`classification.refresh(token, positions, walkStartedAt)`** — новый шаг.
4. `mapPortfolio` записывает `sector`/`issuer` из возвращённой Map в Snapshot.
5. `repository.publish`.
Шаг стоит **до** publish, потому что `publish` переводит attempt в `complete` (`portfolio.repository.ts:121-131`), а `rejectMembershipToken` меняет только `running` attempt (`tbank-access.service.ts:233-251`). После publish отказ token не пометил бы подключение недействительным. До publish ошибка `InvalidToken` попадает в существующий `catch` (`portfolio.service.ts:285-291`): Snapshot не публикуется, подключение получает `invalidToken: true`, GET отвечает `400 INVALID_TBANK_TOKEN` через `TbankAccessService.local` (`tbank-access.service.ts:270-272`). Это ровно D3, новых путей ошибок нет.
Все остальные ошибки шага, включая ошибку записи в БД, ловит `refresh`. Он логирует событие `portfolio.classification.failed` только с `code` (без userId, токена и позиций) и возвращает ранее сохранённые значения. Publish продолжается (D4).
Отвергнуто: асинхронный шаг после publish по образцу metadata. Он требует отдельного durable work на каждый инструмент и отдельного fence для отказа token, а новых сведений не добавляет.
**B3. Начало обхода передаётся явно** (D1)
`TbankHourlyScheduler.walk` (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:134`) фиксирует `walkStartedAt = new Date()` и передаёт его через `dispatchPortfolio` в `portfolio.request(userId, revision, accountId, true, walkStartedAt)` (`:280`). Сейчас других вызывающих `request` нет.
Правило: поле нужно получать, если `fetchedAt == null` или `walkStartedAt − fetchedAt ≥ 24 ч`. `fetchedAt` — момент получения ответа, как в сценариях PVΔ «Обновление сектора и эмитента». Фактический период обновления ≈ 25 ч принят владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)). Отвергнуто: брать время из durable-строки `TbankSyncWork`, потому что строка переиспользуется по `scopeKey` и не хранит время старта.
**B4. Вызовы T-Bank** (D2, D6)
- Новые методы `TbankClient`:
- `getEtf` (`etfBy`, sparse service, как `getBond`);
- `getAssetBrand(token, assetUid)` → `{assetUid, brand{uid,name}} | null` (`getAssetBy`);
- необязательный параметр `notFound: true` в `getShare`/`getBond`/`getEtf`.
Существующие `getShare`/`getBond`/`getAssetDescription` остаются для каталога без изменений.
- **NOT_FOUND.** Сейчас NOT_FOUND превращается в `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальные методы получают Unavailable (`tbank.client.ts:577-583`). Приватный `call()` получает opt-in флаг, и условие становится `opt-in || shareBy || metadataCall`. Opt-in включают только новые вызовы классификации. Глобальное расширение списка методов отвергнуто: для `getAssetBy` оно превратило бы отсутствие описания в карточке компании в 404 вместо 503, а для `bondBy` поменяло бы ответ каталога облигаций. Это observable-изменения вне scope.
- **Порядок для позиции STOCK/BOND/FUND:** `ShareBy`/`BondBy`/`EtfBy` по UID; при NOT_FOUND по UID и наличии FIGI — один повтор по FIGI (тот же порядок, что в PVΔ «Нормализованный состав Portfolio» и metadata). Это design-решение в рамках формулировки data-mapping «по instrument UID, затем по FIGI»: формулировка не уточняет, нужен ли переход на FIGI после NOT_FOUND по UID, и design его выбирает. Specs и data-mapping не меняются. Ответ даёт `sector` и `asset_uid`. Затем `GetAssetBy(asset_uid)`, если бренд нужно получать. Если инструмент в этом обходе не получен, используется сохранённый `assetUid`.
- **Дедупликация.** Singleton-сервис держит Map in-flight promises: ключ инструмента для `*By` и `asset_uid` для `GetAssetBy`. Повтор внутри одного шага и параллельные attempt разных счетов с одним инструментом или активом делят один RPC. После завершения promise удаляется: это не кэш.
- **Квоты.** Цикл последовательный (`await` на каждый инструмент), все RPC проходят через существующий `call()` с RateLimiter, retry и deadline. Вложенного retry нет. Переполнение очереди даёт Unavailable, то есть исход «иная ошибка». Ожидаемый объём — 2 RPC на инструмент раз в сутки плюс повторы при неуспехе; до 200 инструментов это около 80 с при 5/с, в пределах `WORK_WAIT_BOUND_MS` 15 мин (`tbank-hourly.scheduler.ts:30`).
**B5. Отображение исходов** (D2; failure-model «Классификация»)
| Наблюдение | Действие с полем | `*FetchedAt` |
| --- | --- | --- |
| Непустое значение после `trim`; для бренда — непустая пара `uid`/`name` | Записать | `now` |
| `NotFoundException`, пустое поле, нет `asset_uid`, нет актива или бренда, неполная пара | `null` | `now` |
| `uid`/`figi` ответа ≠ запрошенному; `asset.uid` ≠ `asset_uid` | Не менять | Не менять |
| Unavailable, queue full, ошибка декодирования, ошибка БД | Не менять | Не менять |
| `InvalidToken` | Пробросить (B2) | — |
Типы `OTHER`, `CURRENCY` и прочие не запрашиваются: `sector`/`issuer` = `null` (PVΔ «Нормализованный состав Portfolio»). Для FUND `issuer` заполняется, и разрез «Эмитенты» его отбрасывает на клиенте (PC «Состав разрезов»).
**B6. DTO и OpenAPI** (D7)
- `PortfolioPositionDto` (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`) получает поля:
- `@ApiProperty({ type: String, nullable: true }) sector`;
- `@ApiProperty({ type: PortfolioIssuerDto, nullable: true }) issuer`, где у `PortfolioIssuerDto` обязательные `id` и `name`.
Используется тот же шаблон nullable-объекта, что у `currentValue: MoneyDto | null`. Это OpenAPI 3.0, синтаксис 3.1 не вводится.
- Backend: `npm run openapi:generate` записывает корневой `openapi.json` (не в Git). Frontend по правилу `frontend/.ai/conventions/data.md` «Типы и contract» получает artifact явно: он кладётся в `frontend/src/shared/api/spec/openapi.json` в frontend PR, затем `npm run openapi:generate` и `npm run openapi:check`. Frontend не читает backend checkout.
**B7. Старые Snapshot** (D7)
`PortfolioService.saved()` при чтении payload (`portfolio.service.ts:90`) нормализует каждую позицию: `sector: p.sector ?? null`, `issuer: p.issuer ?? null`. Сохранённые payload не переписываются. Следующий успешный часовой обход публикует Snapshot с полями. Data-миграции нет.
### Frontend
**F1. Чистая функция разрезов в `pages/account/model/concentration.ts`** (D8, D12)
- **Слой.** Вкладка нужна только странице счёта, поэтому `pages/account`, а не widget или entity (`frontend/.ai/architecture.md:32`).
- **Сигнатура.** `toConcentration(portfolio, slice, currency)` → `{ currencies, items, other, total, top5Share, unaccounted, empty }`.
- **Математика.** Используются только `decimalFromString`/`sumDecimals`/`decimalToString`/`formatDecimal` из `@/shared/lib` и константы масштаба по образцу `positions.ts` (`PERCENT_SCALE`, `formatPositionReturnPercent` с округлением середины вверх на bigint, `frontend/src/pages/account/model/positions.ts:40-69`). Доля считается как bigint-частное с запасом масштаба; показ — округление до десятых с серединой вверх; положительная доля < 0,1 даёт «< 0,1 %». Доля «Прочее» и «Топ-5» — частное от точной суммы, поэтому сумма точных долей = 100 % по построению.
- `formatShare` из `entities/portfolio/model/display.ts` не используется: он считает через `Number`.
- Порядок равных сумм (PC «Порядок элементов с равной суммой») и выбор имени эмитента (PC «Название эмитента при разных названиях бренда») реализуются внутри модели. Тесты — по oracle из `analysis/test-cases/`.
**F2. Композиция** (PC «Вкладка «Концентрация» страницы счёта», PC «Вкладка в состояниях страницы»)
- В `AccountPageLoaded` (`frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:108-112`) добавляется `Tabs.Tab value="concentration"` сразу после «Позиции», только при `accountId !== "all"`. Состояние `loaded` уже исключает загрузку, ошибку и пустой Portfolio (`AccountPage.vm.ts:110-121`), поэтому PC «Вкладка в состояниях страницы» выполняется без новой логики.
- Панель — `components/Concentration/Concentration.view.tsx` (+ `.module.css`). View получает готовую модель и именованные действия; data hooks внутри view нет (`ui.md` «Container, VM и view»).
**F3. State в page VM** (D9, D10)
- `useAccountPageViewModel` владеет `{ accountId, tab, slice, currency }` в одном `useState`. Если сохранённый `accountId` ≠ текущему, VM при render выдаёт значения по умолчанию (`positions`, `stocks`, первая валюта) без effect. Так смена счёта считается новым заходом. Повторное открытие маршрута монтирует страницу заново.
- `Tabs` становятся управляемыми (`value`/`onChange`), `keepMounted={false}` сохраняется: state живёт выше панели.
- Если выбранной валюты нет в новом `currencies` (данные обновились), модель берёт первую валюту.
- `toConcentration` вызывается в VM только при `tab === "concentration"`. Мемоизация не нужна: функция линейна по числу позиций.
**F4. Подавление страничного Alert** (D10)
В `AccountPageLoaded.view.tsx:77-79`: `props.partial ? (tab !== "concentration" && <Alert …investment.partial/>) : <CurrencyCards/>`. На вкладке показывается собственный `Alert role="status"` «Данные неполные» (PC «Предупреждение о неполных данных»). Остальные вкладки не меняются. Карточки валют при неполноте не показываются и на этой вкладке, как в AS-IS. Источник: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
**F5. DonutChart без прямого recharts** (D11; ux-design D7)
Проверено на `@mantine/charts` 9.7.3 (`frontend/package.json:41`, `node_modules/@mantine/charts/lib/DonutChart/DonutChart.d.ts:28-64`):
- `chartLabel` выводит только одну строку `<text>`. Поэтому центр — HTML-оверлей `Text` в `position: relative` контейнере поверх `DonutChart`: две строки, точная сумма без обрезки, ступенчатое уменьшение шрифта через CSS. Оверлей помечен `aria-hidden`, текстовый эквивалент даёт `aria-describedby`.
- `cellProps(series)` пробрасывает SVG-атрибуты в каждую ячейку (`node_modules/@mantine/charts/esm/DonutChart/DonutChart.mjs:51,72-78`: `cellProps` передаётся в построение `pieData`, которое уходит в `<Pie data>`). Отсюда задаются обводка и `fill="url(#…)"` для штриховки «Прочее» и «не указан». Узоры (`<pattern>`) лежат в отдельном скрытом `<svg width=0 height=0 aria-hidden>` в той же панели: ссылки `url(#id)` действуют в пределах документа, id берутся из `useId`.
- Цифры ранга на сегментах ≥ 4 % рисует `pieProps.label` (render-функция). `pieProps` разворачивается после встроенного `label`, поэтому он его переопределяет.
- `role="img"`, `aria-label`, `aria-describedby` передаются через `...others` на root `Box`.
- `name` в data — уникальный ключ строки; тип ячейки находится по ключу в замыкании `cellProps`.
Прямой `recharts` (уже зависимость 3.10.1) не нужен. Отвергнуто: собственный SVG-donut — лишний код без driver.
**F6. Подписи секторов** (PC «Подписи секторов», PC «Русские подписи известных секторов»)
Используется шаблон `t(\`stock.sectors.${sector}\`, { defaultValue: sector })` (`frontend/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx:64`) и словарь `stock.sectors` (`frontend/src/shared/i18n/locales/ru.ts:759-770`). Добавляется недостающий ключ `government: "Государственный сектор"` в этот общий словарь. По решению владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) подпись показывается и в каталогах — везде, где выводится сектор через этот словарь (страница акции). Список вариантов фильтра скринера (`frontend/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx:46-62`) не меняется: требования к фильтру нет. Неизвестный код показывается как есть через `defaultValue`. **Это observable-изменение вне Specs:** подпись на странице акции меняется с сырого `government` на «Государственный сектор». Оно одобрено владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)) и фиксируется в Gate Log G3; Specs не дополняются. Проверка — task 2.7. Новые строки вкладки добавляются в `portfolio.concentration.*`.
## Components and Responsibilities
| Компонент | Ответственность | Зависит от |
| --- | --- | --- |
| `TbankClient` (+`getEtf`, `getAssetBrand`, opt-in NOT_FOUND) | Типизированные RPC через общую очередь | gRPC, RateLimiter |
| `PositionClassificationService` (portfolio/application) | Решает, нужно ли получать данные (B3); выполняет RPC и дедупликацию (B4); отображает исходы (B5); пробрасывает только `InvalidToken`; возвращает Map `instrumentKey → {sector, issuer}` | `TbankClient`, `PortfolioRepository` |
| `PortfolioRepository` (+`readClassifications`, `saveClassification`) | Чтение и upsert строк классификации | Prisma |
| `PortfolioService` | Встраивает шаг до publish; нормализует старые Snapshot | Сервис выше |
| `tbank-portfolio.mapper` | Записывает `sector`/`issuer` в позицию по ключу `metadataKey(position)` | — |
| `TbankHourlyScheduler` | Передаёт `walkStartedAt` | — |
| `pages/account/model/concentration.ts` | Разрезы, суммы, доли, «Прочее», N не учтённых, валюты | `@/shared/lib` |
| `AccountPage.vm.ts` | State вкладки, разреза и валюты; вызов модели | модель |
| `AccountPageLoaded` / `Concentration.view` | Композиция, Alert, диаграмма, список | Mantine, `@mantine/charts` |
Направление зависимостей не меняется: integrations ← modules/portfolio ← tbank-sync; `pages → entities → shared`.
## Data / Storage Changes
Новая Prisma-модель (SQLite, `backend/prisma/schema.prisma`) в стиле `TbankInstrumentLookup`:
```prisma
// Public per-instrument classification; no holdings or credentials.
model TbankInstrumentClassification {
provider String
instrumentKey String // metadataKey(position): JSON [uid|null, figi|null]
sector String?
sectorFetchedAt DateTime?
assetUid String?
brandUid String?
brandName String?
brandFetchedAt DateTime?
@@id([provider, instrumentKey])
}
```
- Ключ — существующий `metadataKey` (`instrument-metadata.repository.ts:33-38`), без FK на `TbankInstrument`: строка инструмента может ещё не существовать (B1).
- Данные публичные и не принадлежат пользователю или подключению, как и `TbankInstrument`. Fence по connection revision не нужен. Публикация Snapshot по-прежнему ограждена (`portfolio.repository.ts:121-131`).
- Миграция `…_add_instrument_classification` (`prisma migrate dev --create-only`) делает только `CREATE TABLE`. Прежние таблицы и payload не меняются.
- Snapshot (`TbankPortfolio.payload`) получает два поля у позиций. Схема JSON неформальная, поэтому миграция не нужна.
## Contract Implementation
| Контракт | Producer → Consumer | Как производится / потребляется | Сверка с target |
| --- | --- | --- | --- |
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio`, `positions[].sector`/`issuer` | Backend → frontend | Nest DTO (B6) → `npm run openapi:generate` → artifact передаётся явно → `frontend/src/shared/api/spec/openapi.json` → `npm run openapi:generate` → generated `paths` | Contract Parity на G4: поля, nullable, required и `issuer.{id,name}` сравниваются с таблицей «Семантическая delta» system-analysis; `npm run test:openapi-contract` подтверждает, что генерация совпадает с runtime |
| T-Bank `ShareBy`/`BondBy`/`EtfBy`/`GetAssetBy` | T-Bank → backend | Через существующий proto loader; vendor proto не меняется (RPC уже есть: `instruments.proto:24,42,65,92`) | Unit-тесты client на маппинг полей и NOT_FOUND |
Ошибки HTTP не меняются: `400 INVALID_TBANK_TOKEN` выдаёт существующий путь.
## Cross-cutting Implementation
- **Security.** Токен расшифровывается только в attempt (`tokenForAttempt`) и в логи не попадает. Raw gRPC наружу не уходит. Новых прав и секретов нет (security-analysis не выбран, process-record).
- **Observability.** Используется существующий `tbank.rpc.failed` на каждый RPC и одно событие `portfolio.classification.failed { code }` на неуспешный шаг. Новых метрик нет (observability не выбран).
- **Concurrency.** In-flight Map в singleton (B4). Upsert одной строки из двух attempt идемпотентен, побеждает последняя запись с тем же содержимым. Один процесс backend — по ADR 015 и scheduler (`ponytail` в `tbank-hourly.scheduler.ts:42-43`).
- **Freshness.** Классификация не входит в `meta.dependencies` и не меняет `isStale` или `lastSuccessfulAt` (D4). `dataRevision` Snapshot и так растёт при каждой публикации.
## UI Implementation
С ux-design описывается только инженерная часть.
| UI-состояние | Как получается |
| --- | --- |
| Вкладки нет | `accountId === "all"` либо VM-состояние не `loaded` |
| Разрез / валюта / раскрытие «Прочее» | `slice`, `currency` — в VM (F3); раскрытие — локальный `useState` view: сброс при уходе с вкладки допустим, потому что PC «Раскрытие строки «Прочее»» не требует его сохранять |
| Пустой разрез | `model.empty`; выбор валюты и сноска остаются (PC «Пустое состояние разреза») |
| «Данные неполные» | `partial` из VM (`AccountPage.vm.ts:124`) → Alert вкладки; страничный подавлен (F4) |
| Выбор валюты скрыт | `currencies.length <= 1` |
| Сноска N | `model.unaccounted > 0` |
| Центр и текстовый эквивалент | `model.total`, `model.top5Share` → оверлей и скрытый текст (F5) |
Visual Package: используется v2 из ux-design (локальные `visuals/*.svg`, хеши — в ux-design «Materials»). В design новых визуальных материалов нет: реализация не добавляет экранов и состояний сверх v2.
## Developer Verification Strategy
Проверки задают implementation owners. QA-матрица — в qa-analysis.
**backend** (команды из `backend/AGENTS.md` и conventions):
- `npx prisma validate`, `npx prisma generate`, `npm run typecheck:app`;
- проверка миграции на disposable копии БД с существующими Snapshot: старые payload читаются, GET отдаёт `sector: null` и `issuer: null`;
- unit-тесты `PositionClassificationService`: четыре исхода по каждому полю отдельно; границы 23:59 и 24:00 от `walkStartedAt`; «не получено → каждый обход»; дедупликация по `asset_uid`; использование сохранённого `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет;
- `portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован и вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован с прежними значениями;
- `tbank.client.spec.ts`: opt-in NOT_FOUND для `bondBy`/`etfBy`/`getAssetBy`; прежние методы не изменились;
- `npm run test:openapi-contract`, `npm run openapi:generate`, `npm run test:e2e` (portfolio HTTP: shape и `400 INVALID_TBANK_TOKEN`);
- финально `npm run verify`.
**frontend** (команды из `frontend/AGENTS.md`):
- предоставленный `openapi.json` → `npm run openapi:generate`, `npm run openapi:check`;
- vitest для `concentration.ts` по oracle из `analysis/test-cases/`;
- `AccountPage.vm.test.tsx`: сохранение разреза и валюты между вкладками, сброс при смене счёта;
- `AccountPage.test.tsx`: вкладки нет для `all`; Alert подавлен только на «Концентрации»;
- browser-тест раскладки 320 px по образцу `position-layout.browser.test.tsx`;
- финально `npm run verify`.
**specs:** `openspec validate account-top-positions --strict`, `git diff --check`.
**docs:** `npm run verify` (`docs/CONTRIBUTING.md`).
## Migration / Rollout / Rollback
**Порядок выката:**
1. Backup БД и ключа шифрования по ADR 015.
2. Backend: миграция `CREATE TABLE` и код. Новые поля сразу в DTO (`null` до первого обхода).
3. Публикация artifact OpenAPI.
4. Frontend PR с новым snapshot и вкладкой.
Совместимость: старый frontend игнорирует новые поля, поэтому изменение аддитивно. Первый обход после выката запрашивает данные всех позиций: разовый всплеск RPC, B4.
**Откат:**
- **Frontend.** Revert PR; backend продолжает отдавать поля.
- **Backend.** Откат на предыдущий bundle (ADR 016). Лишняя таблица не читается.
- Фактический запуск: production image стартует `node dist/src/main.js` (`backend/Dockerfile`, `CMD`); приложение миграции не применяет и `_prisma_migrations` не проверяет (`backend/src/prisma/prisma.service.ts`). Миграции применяет отдельный compose-сервис `migration` (`prisma migrate deploy`, `deploy/compose.yaml:32-37`), от которого зависит `backend` (`depends_on … service_completed_successfully`, `:43-45`). Штатный `deploy.sh` запускает его на каждом обновлении (`deploy/deploy.sh:183-186`); ручной image rollback по `deploy/README.md` «Ручной откат и восстановление» делает `up --no-deps backend` и миграцию намеренно не запускает (то же — `docs/architecture/deployment.md` «Откат image»).
- Следствие: при ручном откате старый backend стартует на схеме с лишней таблицей, запись о новой миграции в `_prisma_migrations` остаётся. Если вместо ручного отката запустить `deploy.sh` со старым тегом, старый `migrate deploy` увидит применённую миграцию, которой нет в его каталоге. По документации Prisma `migrate deploy` применяет только недостающие локальные миграции и не сверяет историю, а `migrate status` сообщает о ней как о «applied but missing locally»; это не подтверждено на версии проекта и проверяется в task 3.4. Snapshot с полями `sector`/`issuer` читается старым кодом без ошибок: поля лишние, ответ проходит через сериализацию DTO. На G4 это нужно проверить: whitelist сериализации в backend не найден, поэтому старый код может отдавать лишние поля как есть, и это безвредно.
- **БД.** `DROP TABLE "TbankInstrumentClassification"` — по желанию, данные восстанавливаются следующим обходом. Restore из backup не нужен.
Необратимых шагов нет.
## Solution Assessment
Обязателен: новое хранение и кеширование данных внешней интеграции.
| Критерий | A. Новая таблица + шаг до publish (**выбрано**) | B. Расширить `InstrumentMetadataService` / `TbankInstrument.metadata` | C. Хранить в payload Snapshot с переносом из прежнего |
| --- | --- | --- | --- |
| D1 возраст по полю | Явные столбцы | Поля возраста внутри JSON; меняется `fresh = complete`, а через `metadataDependencies` и `meta` GET (`portfolio.service.ts:122-138`) | Перенос между Snapshot; при новом подключении (новая revision) значения теряются |
| D2 четыре исхода | Простой upsert по полю | Drain перезаписывает весь payload и бросает `Incomplete metadata` / `mismatch` (`instrument-metadata.service.ts:141-186`): исходы смешиваются с логикой name/ticker | Реализуемо, но дублируется на каждый счёт |
| D3 отказ token | Существующий catch attempt | Работает через собственный attempt metadata, но после publish | Как A |
| Связность / риск | Новый сервис около 100 строк, таблица | Высокий: конкурентная очередь producers/observers, общая для GET и каталога | Средний: логика переноса в mapper |
| Обратимость | `DROP TABLE` | Трудно отделить от общего кэша | Payload остаётся в истории |
| Новая технология | Нет | Нет | Нет |
Также отвергнуто: `TbankInstrumentSource`. FK на `TbankInstrument` требует сначала создать строку инструмента, а она служит payload общей metadata и каталога: upsert перезаписал бы `metadata` для GET.
Условие пересмотра A: если появится второй потребитель сектора или бренда (например, каталог), таблицу стоит сделать общим справочником с собственным владельцем, оформив это через ADR.
## Risks / Trade-offs
| Риск / компромисс | Влияние | Мера |
| --- | --- | --- |
| Квоты T-Bank при первом обходе и при постоянных отказах (Q-F3, qa-analysis Gaps) | Шаг Portfolio длиннее; queue full даёт исход «иная ошибка» | Последовательный цикл, дедупликация; ceiling — `WORK_WAIT_BOUND_MS`. Квоты и полнота данных (Q-D1, Q-D2, Q-F3) проверяются на реальном токене в QA до G4 — решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) |
| `fetchedAt` = момент ответа, поэтому фактический период ≈ 25 ч (обход раз в час, ответ позже старта) | Обновление на час позже «суток» | Принято владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)); соответствует сценариям PVΔ «Обновление сектора и эмитента» буквально |
| Шаг до publish удлиняет время до нового Snapshot | Финансовые данные публикуются на секунды–минуты позже | Только когда данные нужно получать; иначе одно чтение из БД |
| Отказ token в классификации блокирует Snapshot | Намеренно, D3 | — |
| Карточка позиции считает долю через `Number` (`formatShare`), вкладка — точно | В граничных случаях (x,x5) показ может расходиться на 0,1 п.п. | **Принятый компромисс:** карточка не меняется (вне scope), вкладка следует точному расчёту PC «Расчёт долей»/PC «Показ долей»; Specs не меняются |
| Ключ `metadataKey` различает «UID» и «UID+FIGI» | Возможны дубли строк одного инструмента | Безвредно: дополнительный RPC раз в сутки |
| `government` появится и в каталогах | Подпись вместо сырого кода | Решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); корректно по таблице PC «Русские подписи известных секторов» |
**Решения владельца ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)):** период ≈ 25 ч приемлем (B3); квоты и полнота данных на реальном токене проверяются в QA до G4; «Государственный сектор» — в общем словаре и в каталогах (F6). Открытых findings для Specs и analysis нет: переход UID→FIGI после NOT_FOUND — design-решение B4, расхождение float/точно — принятый компромисс.
## ADR Candidates
- **ADR 022 «Хранимая классификация позиций (sector, brand) с суточным обновлением в часовом обходе»**, статус `Accepted` (решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)). Пишется в docs-задаче после реализации, как AS-IS (`docs/adr/022-stored-instrument-classification.md`, по analysis/docs-impact.md «ADR Decision»). Дополняет ADR 019 исключением, которое ввела PVΔ «Свежесть и fallback полного Portfolio Snapshot». Альтернативы — B и C из Solution Assessment. Последствия: первый долговременный справочник T-Bank, условие пересмотра при втором потребителе.
- Opt-in NOT_FOUND в `TbankClient` — локальное решение, не ADR.
@@ -0,0 +1,60 @@
# Process Record
**Mode:** lab
**Class:** complex — межсистемно (backend + frontend + T-Bank), синхронизация внешних данных и финансовые расчёты долей
**Issue:** https://git.personal.ksv741.ru/WealthLedger/main/issues/48
## Change Profile
| Артефакт | Выбран | Причина |
| --- | --- | --- |
| research | yes | Trigger: новый продуктовый функционал и вопрос владельца «какой ещё есть критерий?». Нужно сравнить критерии концентрации и формы визуализации в других продуктах. Итог — `analysis/research.md`. |
| ux-design | yes | Trigger: новый экран, вкладка «Концентрация» с четырьмя разрезами, выбором валюты, диаграммой и «Прочее». Отдельно нужно решить, как она соотносится с существующей «Структурой портфеля». |
| system-analysis | yes | Trigger: меняется контракт Portfolio, в позициях появляются сектор и эмитент. Новые данные берутся из внешней интеграции T-Bank (инструмент, актив и его бренд). |
| data-mapping | yes | Trigger: данные T-Bank (sector, asset → brand) нужно преобразовать в данные позиций Portfolio, а коды секторов — в русские подписи. Нужно правило для пустых и неизвестных значений. |
| failure-model | yes | Trigger: обогащение позиций идёт через внешнюю интеграцию в часовой синхронизации. Отказ или неполнота данных T-Bank не должны ломать Portfolio Snapshot и его свежесть (Open Decision 4 Proposal). |
| nfr | no | Число позиций одного счёта ограничено, вкладка считается по уже загруженному Portfolio. Нагрузку на T-Bank и лимиты запросов рассматривает failure-model; отдельных обязательств по задержке, объёму и хранению нет. |
| observability | no | Новых фоновых процессов нет: обогащение идёт внутри существующей синхронизации с её логированием. Недоступность данных видна пользователю как явные группы «не указан»; частичный успех обогащения (Snapshot сохранён, часть данных не получена) фиксируется в failure-model и существующих логах синхронизации без новых метрик и алертов. |
| security-analysis | no | Модель доступа и trust boundaries не меняются. Сектор и бренд — публичные справочные данные инструмента, к Portfolio того же User добавляются только они. Новые запросы к T-Bank идут под уже существующим токеном подключения того же User, новых секретов и прав нет. |
| test-cases | yes | Trigger: финансовые расчёты (суммы, доли, топ-5, «Прочее», сумма долей = 100 %) и много граничных случаев: одна валюта из нескольких, меньше пяти элементов, равные суммы, нет сектора или бренда, шорт, неоценённые позиции. Для проверки нужны конкретные данные и расчётный oracle. |
| docs-impact | yes | Trigger: несколько docs targets — домен Portfolio, страница интеграции T-Bank (новые используемые данные) и глоссарий (Концентрация, Сектор, Эмитент). |
## Gate Log
| Gate | Дата | Scope | Validation | Findings | Decision |
| --- | --- | --- | --- | --- | --- |
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate` — ожидаемая ошибка «no deltas» до specs; readiness: plan OK (proposal, process-record, research); HTML-комментариев нет; `git diff --check` OK | G1 review independent-reviewer: 1 blocker, 4 major, 6 minor — закрыты решениями владельца ([issuecomment-3077](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)) и правками; повторный review: ready | approved "вроде все ок" |
| G1 | 2026-10-30 | proposal | — | G2 review B1: правило валют вкладки противоречит proposal | invalidated: решение владельца по B1 ([issuecomment-3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)) меняет правило валют в proposal |
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate --strict` valid; readiness: plan OK; proposal обновлён по 3079 (свой список валют вкладки) | G2 re-review independent-reviewer: B1 закрыт в proposal | approved "Да, G1 ок" ([issuecomment-3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)) |
| G2 | 2026-10-30 | specs | `openspec validate --strict` valid; `git diff --check` OK; readiness: plan OK; self-check по spec-writing.md | G2 review qa-engineer + independent-reviewer: 1 blocker (B1, валюты), 4 major, ~12 minor — закрыты решениями владельца (3079, 3080) и правками; финальный re-review: ready | approved "да, идем дальше" |
| G2 | 2026-10-30 | specs | — | system-analysis/ux-design: эмитент у фондов, обновление metadata, имя эмитента при расхождении, поведение вкладки (сохранение выбора в пределах страницы, скрытый Select, метрика ≤5, partial) | invalidated: решения владельца ([issuecomment-3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) меняют Specs |
| G2 | 2026-10-30 | specs, system-analysis, data-mapping, failure-model | `openspec@1.13.1 validate --strict` valid; `validate --specs` 33/33; `git diff --check` OK; readiness: plan OK; Coverage 32/32 | G2 rev2/rev3 review independent-reviewer + qa-engineer: 1 blocker (B1 — противоречие canonical «Свежесть и fallback»), 3 major, ~8 minor — закрыты решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) и правками; финальный re-review: ready | approved "ок, давай дальше" |
| G3 | 2026-10-31 | design, ux-design, qa-analysis, test-cases, docs-impact, tasks | `openspec@1.13.1 validate --strict` valid; `git diff --check` OK; readiness: apply OK; Visual Package v2 SHA-256 сверены reviewer; Coverage 32/32 | G3 review independent-reviewer + frontend-engineer: 4 major, 4 minor, Q2 — закрыты; backend-engineer не выполнен (квота API), backend-утверждения design сверены основным агентом с кодом; повторный review: ready. Включает изменение вне Specs: подпись «Государственный сектор» на странице акции и в каталогах (3084) | approved "да, все окей, идем дальше" |
| G4 | 2026-11-02 | archive | Код и docs в `develop`: backend 17432df, frontend 70a2c33, docs 37430da; `openspec@1.13.1 validate --strict` valid | Independent Review, QA и проверка отката не выполнялись | archived without verification: решение владельца в сессии specs#74 (descope 3.1–3.4, 5.1) |
Примечание к G2 (2026-10-30): GIVEN «сектор и бренд актива позиции ещё ни разу не получены» в Scenario «Недоступность сектора и эмитента не нарушает Snapshot» (finding N1) и замена строки design-обязательства в failure-model (N2) внесены основным агентом до записи approved G2 и входят в утверждённую версию.
## Descoped Work
| Task | Причина | Gate |
| --- | --- | --- |
| 3.1 Independent Review | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
| 3.2 QA test-cases | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
| 3.3 QA exploratory | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
| 3.4 Откат backend | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
| 5.1 Запись G4 с evidence | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
## Lab Journal
| Стадия | Роль / сабагент | Уникальные находки | Решения | Риски найдены / исключены | Проблемы передачи | Skills | Стоимость | Переносимо |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| research | product-researcher | В Portfolio DTO нет сектора и эмитента, хотя T-Bank их отдаёт. Доля топ-5 понятнее HHI. Общий мультивалютный рейтинг без конвертации некорректен. «Структура» дублировала бы существующий виджет. | MVP: акции, облигации, секторы; позже: страна, maturity, riskLevel, P&L, HHI; не делать: кредитный рейтинг, конвертацию, treemap | Найден: полнота sector неизвестна. Исключено: смешение валют | Нет | — | 1 вызов | Шаблон «must / later / no» для выбора критериев |
| proposal | product-analyst + основной агент | Эмитент требует отдельной идентичности; основной агент нашёл в proto T-Bank `AssetFull.brand` {uid, name, company, sector} (краткий `Asset` бренда не содержит). 8 Open Decisions, 3 из них меняют scope | Владелец: «Эмитенты» в MVP (отклонение от research: Later → MVP), эмитент = бренд T-Bank, фонды не входят, госбумаги — по сектору T-Bank, режим lab | Найден: «Эмитенты» зависят от полноты бренда. Исключено: группировка по названию | Валидация change ждёт delta specs (ожидаемо) | — | 1 вызов + 2 вопроса владельцу | Решать scope-вопросы до G1 через короткий опрос |
| G1 review | independent reviewer + product-analyst | Источник бренда указан неточно (`Asset` вместо `AssetFull.brand`); «Секторы» не задавали набор типов; раскрытие «Прочее» было Open Decision; не зафиксировано отклонение от research | Владелец ([Issue #48, комментарий](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)): «Прочее» раскрывается; без бренда — fallback «Эмитент не указан» без нового G1; «Секторы» только STOCK/BOND/FUND; общий топ-5 вне scope | Найден: несоответствие источника и scope. Исключено: зависимость «Эмитентов» от полноты бренда | Proposal правился после review; Gate Log не заполнялся | — | 1 вызов | Сверять ссылки на proto с исходником до G1 |
| specs | product-analyst | Остальные нормы вкладки не требуют новых источников; в canonical нет перечня вкладок страницы счёта и правил выбора валюты «Структуры портфеля» (они только в коде и docs) — правила валюты, округления, равных сумм определены в Specs и вынесены на подтверждение G2. Эмитент и сектор — новые поля позиции Portfolio | 2 дельты: новая `portfolio-concentration` (23 Requirement), `portfolio-view` MODIFIED «Нормализованный состав Portfolio». Решения владельца ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)) внесены | Найден: расхождение «Структуры портфеля» (деньги в знаменателе и в валютах) и вкладки (без денег) — различие зафиксировано явно | Нет | — | 1 вызов | Сверять правила «как в существующем виджете» с canonical до написания, а не с кодом |
| G2 review | qa-engineer + independent-reviewer + product-analyst | Валюты вкладки не были определены независимо от «Структуры»; «Прочее» при шести элементах скрывало одну строку; метод наибольшего остатка противоречил показу долей в карточках; подписи секторов без источника; не было граничных сценариев 5/6 элементов и противоречивый GIVEN | Владелец ([3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)): свой список валют вкладки; округление как в карточках, «< 0,1 %»; «Прочее» от семи элементов. Автор: список валют включает отрицательные и нулевые позиции STOCK/BOND/FUND; `null` считается в сноске при любой валюте | Найден: расхождение формата долей между спеками и кодом. Исключено: инвариант «100,0 %» на показанных значениях | Gate Log ведёт основной агент (G1 инвалидирован) | — | 1 вызов | Сверять формат чисел с кодом существующего виджета до написания норм |
| specs rev2 | product-analyst | G2 инвалидирован решениями [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081): issuer у фондов в данных; обновление sector/brand раз в сутки с последним известным значением; отказ token — как существующая недействительность; trim пробелов; имя эмитента по наибольшей позиции; сохранение выбора в пределах страницы; метрика топ-5 при ≥6 элементах; центр кольца; «Данные неполные» | `portfolio-concentration` 28 Requirement (+5: вкладка в состояниях страницы, название эмитента при разных названиях, центр диаграммы, строки без переходов, предупреждение о неполных данных; «Суммарная доля топ-5» и «Начальное состояние» переписаны); `portfolio-view` MODIFIED + 3 ADDED (срок обновления, последнее известное, ошибки получения) | Найден: «Данные неполные» привязано к `meta` по `tbank-data-sync`, не к неоценённым позициям. Исключено: конфликт metadata/invalid token (отсылка к существующему поведению) | Gate Log ведёт основной агент | — | 1 вызов | Сверять производные analysis-артефакты с Delta Specs до G2 |
| specs rev3 | product-analyst | G2 re-review: хранение sector/brand противоречило «Свежесть и fallback» (только онлайн, без долговременного справочника) и «metadata не определяет успешность»; граница 24 ч была сформулирована как «24 ч + интервал»; «не найдено», пустой ответ и ответ о другом активе не были определены | [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082): исключение для sector/brand; «не найдено» = «значения нет»; равные наибольшие позиции эмитента — по алфавиту; предупреждение о неполных данных только на вкладке | `portfolio-view`: MODIFIED «Свежесть и fallback» (полная копия + 2 исключения и 2 сценария), ADDED «Обновление сектора и эмитента» и «Результат получения сектора и эмитента» (таблица исходов), без дублей; `portfolio-concentration`: название эмитента по алфавиту, предупреждение без дубля | Найден: страничного сообщения о неполноте в canonical «Состояния и ошибки Portfolio на клиенте» нет (требует лишь не выдавать неполный итог за окончательный), поэтому MODIFIED для него не нужен. Исключено: дублирование норм об ошибках | Gate Log ведёт основной агент | — | 1 вызов | Если новая норма хранит данные, сверять с общими «только онлайн» правилами до написания |
| design ∥ qa-analysis | solution-architect, qa-engineer | Общий metadata-кэш не подходит под 24 ч и 4 исхода, нужна отдельная таблица классификации; шаг классификации должен идти до publish, иначе отказ token не инвалидирует подключение; DonutChart 9.7.3 хватает без прямого recharts | Таблица `TbankInstrumentClassification`, opt-in NOT_FOUND только для новых вызовов, состояние вкладки поднято в page VM; владелец: ~25 ч, квоты в QA до G4, «Государственный сектор» в общем словаре (3084) | Найдено: изменение страницы акции вне Specs (одобрено 3084); откат образа без миграции | Субагенты не видели полный текст review, findings пересылались вручную | — | 2 вызова + 3 доработки | Сверять design-ссылки на код до G3 |
| test-cases + docs-impact + tasks | qa-engineer, documentation-engineer, solution-architect | 21 из 30 зарезервированных TC ничего не добавляли сверх Scenario и выброшены; «эмитент» конфликтует с термином docs | 15 TC + TC-CONC-020; глоссарий с оговоркой, ADR 022 (3083); 28 tasks по 4 repo | Найдено: 2 неверных oracle (TC-CONC-007, TC-META-003) | — | — | 3 вызова | Пересчитывать oracle независимым ревьюером |
| G3 review | independent-reviewer, frontend-engineer; backend-engineer — сбой по квоте API (3 попытки) | 4 major, 4 minor, Q2 (откат без миграции); frontend API DonutChart подтверждён | Все findings закрыты; backend-утверждения design (до publish, InvalidToken catch, NOT_FOUND в `call()`, `WORK_WAIT_BOUND_MS`) основной агент сверил с кодом вручную | Остаётся: backend-ревью субагентом не выполнено | Квота API | — | 5 вызовов | Ревью backend повторить на G4 |
@@ -0,0 +1,152 @@
# Proposal
Issue: WealthLedger/main#48
## Why
Страница счёта показывает состав Portfolio списком позиций и распределением по классам активов. Ответа на вопрос «где сконцентрированы деньги» нет: чтобы понять, какие бумаги, отрасли и компании занимают основную часть счёта, владельцу приходится вручную сравнивать карточки позиций. Распределение по классам активов этого не даёт: оно не показывает ни крупнейшие позиции, ни отраслевой или эмитентский перекос.
## What Changes
- На странице счёта рядом с вкладкой «Позиции» появляется вкладка «Концентрация».
- Вкладка содержит четыре разреза текущего состава Portfolio выбранного счёта: «Акции», «Облигации», «Секторы», «Эмитенты».
- В каждом разрезе показаны пять крупнейших элементов по текущей денежной оценке, каждый с суммой и долей, а при семи и более элементах все остальные объединены в одну строку «Прочее» (при шести или меньше элементах каждый показан своей строкой). По нажатию на «Прочее» раскрывается полный перечень остальных элементов, каждый с суммой и долей; пять первых строк при этом не меняются. Рядом с перечнем показана диаграмма распределения, согласованная с перечнем, и суммарная доля пяти первых элементов как показатель концентрации.
- «Акции» и «Облигации» ранжируют позиции типов `STOCK` и `BOND` соответственно. Фонды в эти два разреза не входят.
- «Секторы» агрегируют только позиции типов `STOCK`, `BOND` и `FUND`. Позиции типов `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` и `OTHER` в разрез не входят. Позиции перечисленных трёх типов без сектора попадают в явную группу «Сектор не указан».
- «Секторы» используют сектор инструмента в том виде, как его определяет T-Bank, с русской подписью; государственные облигации определяются значением сектора T-Bank `government` с подписью «Государственный сектор», других собственных классификаций WealthLedger не вводит.
- «Эмитенты» объединяют акции и облигации одной компании в один элемент. Эмитент — бренд актива по данным T-Bank; название эмитента — название бренда (при разных названиях одного бренда — название позиции с наибольшей стоимостью). Эмитент заполняется в данных Portfolio и у фондов, а разрез «Эмитенты» фонды отбрасывает. Позиции без бренда попадают в явную группу «Эмитент не указан» с видимой долей; если бренда нет у многих позиций, разрез всё равно остаётся в MVP, а объём scope не меняется. Фонды в разрез не входят: их эмитент — управляющая компания, а не компании внутри фонда.
- Значения показываются в одной валюте за раз. У вкладки свой список валют и свой выбор, независимые от «Структуры портфеля»: в списке валюты позиций акций, облигаций и фондов; по умолчанию выбрана первая по ISO-коду. При одной валюте выбор не показывается. Конвертации нет, общего итога по нескольким валютам нет. Выбранные разрез и валюта сохраняются, пока User остаётся на странице счёта, и сбрасываются при новом заходе и перезагрузке. Доли показываются в формате карточек позиций (обычное округление до одного знака, «< 0,1 %» для очень малых).
- Для разрезов «Секторы» и «Эмитенты» в данных позиций Portfolio появляются сектор и признак эмитента. Это расширение ответа Portfolio новыми необязательными данными; существующие данные позиций и их смысл не меняются. Изменение не является BREAKING.
## Scope
### In Scope
- Вкладка «Концентрация» на странице одного счёта.
- Разрезы «Акции», «Облигации», «Секторы», «Эмитенты» с топ-5, «Прочее», суммой и долей по элементам, диаграммой распределения и суммарной долей топ-5.
- Раскрытие «Прочее» по нажатию с полным перечнем остальных элементов.
- Явные группы «Сектор не указан» и «Эмитент не указан» с видимой долей.
- Выбор одной валюты, пересчёт всех разрезов вкладки под выбранную валюту.
- Состояния вкладки при отсутствии позиций в выбранной валюте, при отсутствии нужных данных (сектор, эмитент) и при ошибке или устаревании Portfolio — в рамках уже действующего поведения Portfolio.
- Расширение данных позиций Portfolio сектором и признаком эмитента (аддитивно).
- Обновление AS-IS документации домена Portfolio, интеграции T-Bank и глоссария (термины «Концентрация», «Сектор», «Эмитент»).
### Out of Scope / Non-Goals
- Общий топ-5 по всем типам позиций вместе (исходная формулировка Issue): рейтинги строятся только внутри разрезов «Акции», «Облигации», «Секторы», «Эмитенты».
- Страна риска и региональное распределение.
- Сроки погашения облигаций.
- Лидеры роста и падения, доход по позициям и вклад выплат в доход.
- Показатель `riskLevel` T-Bank и кредитный рейтинг агентств.
- Индекс концентрации (HHI) и эффективное число позиций.
- Конвертация валют, единый рейтинг или единая диаграмма по нескольким валютам.
- Замена, изменение или повтор существующей «Структуры портфеля» и вкладки «Позиции».
- Режим «Все счета»: вкладка относится только к странице одного счёта.
- Изменение порядка, состава и свежести Portfolio Snapshot.
## Capabilities
### New Capabilities
- `specs/portfolio/portfolio-concentration/spec.md`: вкладка «Концентрация» — разрезы, ранжирование топ-5, «Прочее», доли и суммарная доля топ-5, выбор валюты, правила учёта позиций, состояния.
### Modified Capabilities
- `specs/portfolio/portfolio-view/spec.md`: нормализованный состав Portfolio расширяется сектором и эмитентом позиции (требование «Нормализованный состав Portfolio»); добавляются требования о сроке обновления, последнем известном значении и ошибках получения сектора и эмитента. Требование «Точные значения и валютные агрегаты» не меняется; новая capability `portfolio-concentration` на него ссылается.
## Impact
- Владелец счёта получает новый экран анализа; существующие экраны и их поведение не меняются.
- Затрагиваются backend (обогащение данных Portfolio), frontend (новая вкладка и её разрезы), specs и AS-IS документация.
- Ответ Portfolio расширяется аддитивно: клиенты, не знающие новых данных, продолжают работать без изменений. Публичные API-артефакты потребуют обновления.
- Часовая синхронизация с T-Bank дополнительно получает данные об инструментах и брендах для позиций; это может увеличить число обращений к T-Bank и время синхронизации. Оценка нагрузки и способ получения — в Design.
- Сбой или неполнота этих данных не должны ухудшать успешность и свежесть Portfolio Snapshot (решено, см. Open Decisions); без новых данных позиции показываются в явных группах «Сектор не указан» и «Эмитент не указан».
- Сектор и бренд актива обновляются не чаще раза в сутки (значения могут отставать от T-Bank не более чем на сутки плюс интервал часового обхода); при неуспешном обновлении сохраняется последнее известное значение. Пробелы по краям обрезаются, значение из одних пробелов считается отсутствующим. Отказ token при получении сектора или бренда обрабатывается как существующая недействительность подключения T-Bank.
- На вкладке: в центре диаграммы «Топ-5 · N %» (при шести и более элементах) и сумма разреза, без логотипов и переходов в строках, предупреждение «Данные неполные» при неполном Portfolio; вкладки нет при загрузке, ошибке и пустом Portfolio.
- Существующие данные Portfolio, порядок позиций и итоги по валютам не меняются.
## Success Criteria
- На странице счёта вкладка «Концентрация» доступна рядом с «Позиции»; вкладка «Позиции» и «Структура портфеля» выглядят и работают как раньше.
- В каждом из четырёх разрезов при выбранной валюте при семи и более элементах видны пять строк и «Прочее», при шести или меньше — отдельные строки без «Прочее»; точная сумма строк равна общей сумме разреза в этой валюте.
- Доля каждого элемента и суммарная доля первых пяти считаются от одной и той же базы, а диаграмма и перечень показывают одни и те же элементы и значения; доли показаны в формате карточек позиций и могут в сумме отличаться от 100 % из-за округления.
- Суммы точны и не конвертируются: при выборе валюты в разрезах нет позиций других валют.
- Нажатие на «Прочее» раскрывает все остальные элементы разреза с суммой и долей каждого; состав и порядок топ-5 не меняются.
- В разрезе «Секторы» присутствуют только позиции `STOCK`, `BOND`, `FUND`; позиции других типов в нём отсутствуют. Позиция этих типов без сектора учитывается в группе «Сектор не указан».
- Акция и облигация одного эмитента в разрезе «Эмитенты» показываются одним элементом с общей суммой; фонд в этом разрезе отсутствует.
- Позиция без бренда учитывается в группе «Эмитент не указан», и её доля видна пользователю.
- Общего топ-5 по всем типам позиций на вкладке нет.
- Позиция без вычислимой текущей стоимости не влияет на суммы и доли.
## Evidence
Исследование: `analysis/research.md` (ссылки в формате его источников; здесь не копируются).
- [F] Потребность: показать крупнейшие акции, облигации и секторы по денежной оценке с переключением критерия и диаграммой распределения. Источник: запрос Change Owner, WealthLedger/main#48; `analysis/research.md`, Problem Statement.
- [F] Страница счёта уже содержит распределение по классам активов с выбором одной валюты и вкладку «Позиции» с разделением «Акции / Облигации / Прочее». Источник: `analysis/research.md`, Problem Statement.
- [F] Каноническая `portfolio-view` фиксирует отсутствие валютной конвертации и отдельные итоги по каждой валюте; позиция без вычислимой текущей стоимости остаётся в ответе, но не входит в агрегаты; в Portfolio входят только позиции с ненулевым количеством. Источник: `specs/portfolio/portfolio-view/spec.md`, требования «Нормализованный состав Portfolio», «Точные значения и валютные агрегаты».
- [F] Карточки позиций показывают долю позиции среди позиций той же валюты. Источник: `specs/portfolio/portfolio-view/spec.md`, требование «Представление карточек позиций на клиенте».
- [F] Текущие данные позиции Portfolio не содержат сектора и эмитента. Источник: `analysis/research.md` [L4].
- [F] Сектор есть у инструментов Share, Bond и Etf в интеграционном описании T-Bank. Источник: `backend/src/integrations/tbank/proto/contracts/instruments.proto` (Share, Bond, Etf, поле `sector`).
- [F] Бренд с компанией и сектором (`Brand`, :1220-1229) доступен в полном описании актива `AssetFull.brand` (:1051-1067). Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа. Ссылка инструмента на актив (`asset_uid`) есть у Share (:776), Bond (:543) и Etf (:657). Источник: тот же `instruments.proto`; `analysis/research.md` [L9].
- [F] Разрез «Эмитенты» перенесён из Later research в MVP решением Change Owner (комментарий в Issue #48, issuecomment-3077): условие research «после подтверждения issuer identity» снято наличием `AssetFull.brand`.
- [F] Группировка по эмитенту акций и облигаций описана в публичной справке Т-Инвестиций; секторное распределение подтверждено несколькими продуктами. Источник: `analysis/research.md`, Evidence & Sources [E1, E3, E6, E7].
- [F] Внешние продукты — источник кандидатов, но не доказательство спроса. Источник: `analysis/research.md`, Target Audience.
- [H] Бренд актива T-Bank пригоден как идентичность эмитента; полнота бренда на позициях владельца неизвестна. Группировка по совпадающему названию позиций ненадёжна и не используется. Fallback согласован владельцем: нет бренда → «Эмитент не указан»; полнота проверяется в data-mapping.
- [H] У позиций владельца сектор заполнен в большинстве случаев; доля стоимости без сектора и состав значений (включая «государственные ценные бумаги») неизвестны. Проверяется до Specs на данных Portfolio владельца.
- [H] Сектор у фондов в данных T-Bank присутствует не всегда. Проверяется вместе с предыдущей гипотезой.
- [H] Отрицательная текущая стоимость (короткая позиция) возможна в Portfolio. Спецификация допускает отрицательные значения, но наличие таких позиций у владельца не подтверждено.
- [H] Недоступность сектора или эмитента не должна ломать загрузку Portfolio — по аналогии с логотипом, который является только presentation metadata. Не подтверждено.
## Assumptions / Dependencies
- Базой для вкладки служит сохранённый Portfolio Snapshot со своей свежестью и статусами; вкладка не запускает собственных обновлений.
- У вкладки собственный выбор валюты, независимый от «Структуры портфеля» (решение владельца, [комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)).
- «Структура портфеля» остаётся отдельным блоком; её замена вкладкой «Концентрация» не планируется.
- Данные о секторе и бренде берутся из T-Bank; способ получения и хранения — предмет Design.
- Пять элементов — фиксированный размер топа для всех разрезов; настройка размера не нужна.
- Элементы разрезов «Акции» и «Облигации» — отдельные позиции; одна и та же бумага на одном счёте с одинаковым FIGI не объединяется (действующее правило `portfolio-view`).
## Open Decisions
Все вопросы решает Change Owner. Ни один из них не меняет набор разрезов и не требует нового G1.
**Решены Change Owner до G1** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077); решения внесены в What Changes):
- Эмитент — бренд актива T-Bank; позиции без бренда показываются в группе «Эмитент не указан» с видимой долей. Если бренда нет у многих позиций, разрез остаётся, scope не меняется, новый G1 не нужен.
- Фонды в разрез «Эмитенты» не входят.
- Государственные облигации в «Секторах» — по сектору, как его определяет T-Bank, без собственной классификации.
- «Секторы» включают только `STOCK`, `BOND`, `FUND`; остальные типы позиций в разрез не входят.
- «Прочее» раскрывается по нажатию с полным перечнем остальных элементов; состав топ-5 не меняется.
- Общий топ-5 по всем типам из исходного Issue — вне scope.
- Отклонение от research: там «Эмитенты» были в Later «после подтверждения issuer identity». Условие снято, так как бренд актива T-Bank (`AssetFull.brand`) даёт стабильную идентичность компании; полнота бренда на позициях владельца остаётся гипотезой, её покрывает fallback «Эмитент не указан».
**Решены Change Owner до G2** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078); решения внесены в Delta Specs):
- Знаменатель доли — сумма учитываемых позиций разреза в выбранной валюте; денежные остатки в него не входят.
- Позиции с отрицательной стоимостью и без оценки исключаются из ранжирования, долей и диаграммы; сноска показывает, сколько позиций не учтено.
- Группы «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными элементами и заметно обозначены.
- Отсутствие сектора или эмитента не влияет на успешность и свежесть Portfolio Snapshot, как и логотип; у позиций в таких случаях пустые значения.
**Решены Change Owner после G2-review** ([комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079); внесены в Delta Specs):
- Вкладка имеет собственный список валют и выбор, независимые от «Структуры портфеля».
- Доли показываются с округлением карточек позиций; точные доли в сумме дают 100 %.
- «Прочее» появляется только при семи и более элементах.
**Подтверждены Change Owner** ([комментарий 3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080); внесены в Delta Specs):
- Список валют вкладки: валюты ненулевых `currentValue` позиций акций, облигаций и фондов, включая отрицательные; валюта только с нулевой стоимостью в список не входит.
- В сноске «Не учтено позиций» позиции без оценки считаются при любой выбранной валюте; при пропаже выбранной валюты берётся первая из списка; равные суммы упорядочиваются по названию.
- Название эмитента — название бренда T-Bank.
- Подпись сектора `government` — «Государственный сектор» (добавление WealthLedger к подписям скринера).
**Подтверждены Change Owner** ([комментарий 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081); внесены в Delta Specs):
- Эмитент заполняется у фондов в данных Portfolio, разрез «Эмитенты» их отбрасывает.
- Отказ token при получении сектора или бренда — как существующая недействительность подключения.
- Сектор и бренд обновляются раз в сутки; при ошибке сохраняется последнее известное значение.
- Пробелы обрезаются; при разных названиях одного эмитента берётся название позиции с наибольшей стоимостью.
- Разрез и валюта сохраняются в пределах страницы счёта; выбор валюты скрыт при одной валюте; метрика «доля топ-5» скрыта при пяти и менее элементах; при неполных данных — предупреждение «Данные неполные»; в центре диаграммы «Топ-5 · N %» и сумма; в «Эмитентах» без логотипов и переходов.
Открытых решений нет.
@@ -0,0 +1,495 @@
# portfolio-concentration Specification
## Purpose
Позволяет авторизованному User увидеть на странице одного Brokerage Account, какие акции, облигации, секторы и эмитенты занимают наибольшую часть текущей оценки Portfolio в выбранной валюте, без валютной конвертации.
## ADDED Requirements
### Requirement: Вкладка «Концентрация» страницы счёта
Система SHALL показывать на странице одного Brokerage Account вкладку «Концентрация» рядом с вкладкой «Позиции». Система SHALL NOT показывать вкладку в режиме «Все счета». Добавление вкладки SHALL NOT менять вкладку, открываемую по умолчанию, и содержание остальных вкладок.
#### Scenario: Вкладка на странице одного счёта
- **WHEN** User открывает страницу доступного Brokerage Account
- **THEN** рядом с вкладкой «Позиции» доступна вкладка «Концентрация», а по умолчанию открыта прежняя вкладка
#### Scenario: Режим «Все счета»
- **WHEN** User открывает общий портфель в режиме «Все счета»
- **THEN** вкладки «Концентрация» нет
### Requirement: Вкладка в состояниях страницы
Система SHALL NOT показывать вкладку «Концентрация», пока Portfolio загружается, при его ошибке или недоступности и при штатном пустом Portfolio по требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`. Вкладка SHALL показываться при Portfolio хотя бы с одной позицией или денежным остатком.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
#### Scenario: Загрузка и ошибка
- **WHEN** Portfolio загружается либо получение завершилось ошибкой
- **THEN** вкладки «Концентрация» нет
#### Scenario: Штатный пустой Portfolio
- **WHEN** у счёта нет позиций и денежных остатков
- **THEN** вкладки «Концентрация» нет
#### Scenario: Только денежные остатки
- **WHEN** у счёта есть Cash Balance и нет позиций
- **THEN** вкладка «Концентрация» есть, разрез показывает пустое состояние
### Requirement: Переключение разрезов
Вкладка «Концентрация» SHALL предоставлять разрезы «Акции», «Облигации», «Секторы», «Эмитенты» в этом порядке и показывать одновременно один из них. При новом заходе на страницу счёта SHALL быть открыт разрез «Акции» по требованию «Начальное состояние вкладки». Разрез — набор элементов, на которые делится оценка позиций; элемент — единица ранжирования разреза по требованию «Состав разрезов».
#### Scenario: Открытие вкладки
- **WHEN** User при новом заходе на страницу счёта открывает вкладку «Концентрация»
- **THEN** показан только разрез «Акции» и доступны переключатели «Акции», «Облигации», «Секторы», «Эмитенты»
#### Scenario: Переключение разреза
- **GIVEN** открыт разрез «Акции» с выбранной валютой USD
- **WHEN** User переключает разрез на «Секторы»
- **THEN** показан только разрез «Секторы» в валюте USD
### Requirement: Начальное состояние вкладки
Система SHALL открывать вкладку «Концентрация» при новом заходе на страницу счёта и после перезагрузки с разрезом «Акции» и первой валютой списка. Новый заход — открытие страницы после ухода с неё либо переключение счёта. Пока User остаётся на странице, включая переключение её вкладок, разрез и валюта SHALL сохраняться.
**Источник (ненормативно):** [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
#### Scenario: Возврат с другой вкладки страницы
- **GIVEN** на вкладке выбраны разрез «Секторы» и валюта USD
- **WHEN** User переключается на вкладку «Позиции» и возвращается на «Концентрация»
- **THEN** выбраны разрез «Секторы» и валюта USD
#### Scenario: Повторный заход
- **GIVEN** User выбрал разрез «Секторы» и валюту USD, затем покинул страницу счёта
- **WHEN** User снова открывает страницу счёта и вкладку «Концентрация»
- **THEN** открыт разрез «Акции» с первой валютой списка
#### Scenario: Перезагрузка
- **GIVEN** на вкладке выбраны разрез «Эмитенты» и валюта USD
- **WHEN** User перезагружает страницу
- **THEN** после открытия вкладки показаны разрез «Акции» и первая валюта списка
### Requirement: Состав разрезов
Элементы разреза SHALL формироваться из позиций типов по таблице; другие типы, включая `CURRENCY`, SHALL NOT входить во вкладку.
| Разрез | Типы | Элемент |
| --- | --- | --- |
| Акции | `STOCK` | Позиция |
| Облигации | `BOND` | Позиция |
| Секторы | `STOCK`, `BOND`, `FUND` | Позиции с одним `sector` |
| Эмитенты | `STOCK`, `BOND` | Позиции с одним `issuer.id` |
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
#### Scenario: Сектор объединяет акции, облигации и фонды
- **GIVEN** в RUB есть акция сектора `energy` на 400, облигация `energy` на 100, фонд `financial` на 300 и позиция `CURRENCY` на 50
- **WHEN** User открывает разрез «Секторы» в RUB
- **THEN** элементы — «Энергетика» 500 ₽ и «Финансы» 300 ₽, позиции `CURRENCY` в разрезе нет
#### Scenario: Акция и облигация одного эмитента
- **GIVEN** в RUB есть акция с `issuer.id` `brand-1` («Сбер») на 300, облигация `brand-1` на 200 и фонд `brand-1` на 100
- **WHEN** User открывает разрез «Эмитенты» в RUB
- **THEN** есть один элемент «Сбер» на 500 ₽, фонд в него не включён
#### Scenario: Фонд вне «Акций»
- **GIVEN** в RUB есть фонд на 100 и акция на 200
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показана только акция на 200 ₽ с долей 100 %
### Requirement: Названия элементов
Название элемента в «Акциях» и «Облигациях» SHALL быть отображаемым идентификатором позиции по требованию «Нормализованный состав Portfolio» из `portfolio-view` (name, затем ticker, затем FIGI), в «Секторах» — подписью по требованию «Подписи секторов», в «Эмитентах» — `issuer.name`, то есть названием бренда актива T-Bank. Позиции с разными `issuer.id` SHALL NOT объединяться при одинаковом `issuer.name`.
#### Scenario: Идентификатор позиции без имени
- **GIVEN** акция не имеет name, но имеет ticker `SBER`
- **WHEN** User открывает разрез «Акции»
- **THEN** элемент назван `SBER`
#### Scenario: Название эмитента — название бренда
- **GIVEN** акция и облигация имеют `issuer.id` `brand-1` и `issuer.name` «Сбер»
- **WHEN** User открывает разрез «Эмитенты»
- **THEN** один элемент назван «Сбер»
#### Scenario: Разные эмитенты с одинаковым названием
- **GIVEN** две акции имеют `issuer.name` «Альфа» и разные `issuer.id`
- **WHEN** User открывает разрез «Эмитенты»
- **THEN** показаны два отдельных элемента «Альфа»
### Requirement: Название эмитента при разных названиях бренда
Если учитываемые позиции одного `issuer.id` имеют разные `issuer.name`, название элемента SHALL быть `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте. При равных значениях SHALL выбираться название, меньшее по алфавитному порядку требования «Порядок элементов с равной суммой».
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
#### Scenario: Название по наибольшей позиции
- **GIVEN** акция `brand-1` на 300 с названием «Сбер» и облигация `brand-1` на 200 с названием «Сбербанк»
- **WHEN** User открывает разрез «Эмитенты»
- **THEN** один элемент на 500 назван «Сбер»
#### Scenario: Равные наибольшие значения
- **GIVEN** акция `brand-1` на 200 с названием «Сбербанк» и облигация `brand-1` на 200 с названием «Сбер»
- **WHEN** User открывает разрез «Эмитенты»
- **THEN** элемент назван «Сбер»
### Requirement: Позиции, учитываемые в разрезе
Система SHALL учитывать позицию в ранжировании, долях и диаграмме только при `currentValue` больше нуля в выбранной валюте; остальные позиции SHALL NOT учитываться. Позиция с нулевым `currentValue` не показывается и не считается неучтённой, позиция другой валюты относится к другой валюте выбора.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
#### Scenario: Отрицательная и неоценённая позиции
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** сумма разреза 1 000 ₽, доли 60 % и 40 %
#### Scenario: Позиция с нулевой стоимостью
- **GIVEN** есть акции на 100 и 0 в выбранной валюте
- **WHEN** User открывает разрез «Акции»
- **THEN** показана только акция на 100 с долей 100 %, сноски нет
### Requirement: Сноска о не учтённых позициях
Система SHALL показывать сноску «Не учтено позиций: N» при N больше нуля. N — число позиций разреза с `currentValue` `null` (в любой валюте) либо отрицательным в выбранной.
**Источник (ненормативно):** [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080). Примечание: при неполных данных «Структура портфеля» показывает «Доля недоступна», вкладка — доли по учтённым ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)).
#### Scenario: Сноска с числом позиций
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показана сноска «Не учтено позиций: 2»
#### Scenario: Неоценённая позиция при смене валюты
- **GIVEN** есть акции на 100 USD и 200 RUB и одна акция без `currentValue`
- **WHEN** User выбирает USD
- **THEN** показаны акция на 100 $ с долей 100 % и сноска «Не учтено позиций: 1»
#### Scenario: Отрицательная позиция другой валюты
- **GIVEN** есть акции на 100 RUB и -50 USD
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** сноски нет
#### Scenario: Нет не учтённых позиций
- **GIVEN** все акции имеют положительную стоимость
- **WHEN** User открывает разрез «Акции»
- **THEN** сноски нет
### Requirement: Одна валюта за раз
Система SHALL показывать значения вкладки в одной выбранной валюте, общей для всех разрезов и сохраняемой при их переключении, и при нескольких доступных валютах предоставлять выбор из них. Смена валюты SHALL пересчитывать разрез по позициям этой валюты. Система SHALL NOT конвертировать валюты и показывать общий итог нескольких валют.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
#### Scenario: Смена валюты
- **GIVEN** акции стоят 1 000 RUB и 100 USD, выбрана RUB
- **WHEN** User выбирает USD
- **THEN** разрез «Акции» показывает только акции в USD, сумму 100 $ и долю 100 %, без рублёвых значений
#### Scenario: Валюта сохраняется при смене разреза
- **GIVEN** выбрана USD в разрезе «Акции»
- **WHEN** User переключает разрез на «Облигации»
- **THEN** показаны облигации в USD
### Requirement: Доступные валюты вкладки
Доступные валюты — валюты ненулевых `currentValue` позиций `STOCK`, `BOND` и `FUND`, включая отрицательные (для сноски). Валюта только с нулевой стоимостью в список не входит. Cash Balance и другие типы валюты не определяют. Список упорядочен по ISO-коду, сначала выбрана первая. При одной валюте или без валют выбор SHALL NOT показываться.
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
#### Scenario: Начальная валюта
- **GIVEN** акции оценены в USD, облигации в RUB, а Cash Balance есть ещё в EUR и позиция `CURRENCY` оценена в CHF
- **WHEN** User впервые открывает вкладку «Концентрация»
- **THEN** в выборе RUB и USD, выбрана RUB, EUR и CHF в выборе нет
#### Scenario: Единственная валюта
- **GIVEN** все оценённые акции, облигации и фонды стоят в RUB
- **WHEN** User открывает вкладку «Концентрация»
- **THEN** выбора валюты нет, значения показаны в RUB
#### Scenario: Валюта только у позиции с нулевой стоимостью
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью 0
- **WHEN** User открывает вкладку «Концентрация»
- **THEN** в списке только RUB, USD в нём нет
#### Scenario: Валюта только у отрицательной позиции
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью -50
- **WHEN** User выбирает USD в разрезе «Акции»
- **THEN** показаны пустое состояние и сноска «Не учтено позиций: 1»
#### Scenario: Нет оценённых позиций
- **GIVEN** у всех позиций `STOCK`, `BOND` и `FUND` `currentValue` равен `null`
- **WHEN** User открывает вкладку «Концентрация»
- **THEN** выбора валюты нет, разрез показывает пустое состояние без валюты и сноску с числом таких позиций
### Requirement: Независимый выбор валюты вкладки
Выбор валюты вкладки «Концентрация» SHALL быть независим от выбора валюты в «Структуре портфеля»: смена валюты в одном месте SHALL NOT менять валюту в другом. Если выбранная валюта вкладки пропала из списка после обновления Portfolio, система SHALL выбрать первую валюту списка.
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
#### Scenario: Структура портфеля не влияет
- **GIVEN** в «Структуре портфеля» выбрана USD, а на вкладке «Концентрация» выбрана RUB
- **WHEN** User меняет валюту в «Структуре портфеля» на RUB и обратно
- **THEN** выбранная на вкладке валюта остаётся RUB
#### Scenario: Валюта пропала после обновления
- **GIVEN** на вкладке выбрана USD
- **WHEN** обновлённый Portfolio не содержит позиций `STOCK`, `BOND` и `FUND` в USD
- **THEN** выбрана первая валюта обновлённого списка
### Requirement: Топ-5 и строка «Прочее»
Система SHALL упорядочивать элементы разреза по сумме по убыванию. Если элементов не больше шести, каждый SHALL показываться строкой с названием, суммой и долей. Если семь и больше, SHALL показываться пять первых строк и строка «Прочее» с суммой, долей и числом остальных элементов («2 элемента», «5 элементов»).
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)
#### Scenario: Семь акций
- **GIVEN** в RUB есть семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показаны пять строк с суммами 400, 250, 150, 100, 50 и строка «Прочее» 50 ₽ (2 элемента) с долей 5 %
#### Scenario: Ровно шесть элементов
- **GIVEN** в RUB есть шесть акций с суммами 300, 250, 200, 150, 60 и 40
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показаны шесть строк с долями 30 %, 25 %, 20 %, 15 %, 6 % и 4 %, строки «Прочее» нет
#### Scenario: Ровно пять элементов
- **GIVEN** в RUB есть пять акций с суммами 300, 250, 200, 150 и 100
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показаны пять строк, строки «Прочее» нет
#### Scenario: Меньше пяти элементов
- **GIVEN** в RUB есть три акции
- **WHEN** User открывает разрез «Акции»
- **THEN** показаны три строки, строки «Прочее» нет
### Requirement: Раскрытие строки «Прочее»
По нажатию на «Прочее», в том числе с клавиатуры, система SHALL раскрывать полный список остальных элементов в порядке ранжирования с суммой и долей; повторное нажатие сворачивает его. Раскрытие SHALL NOT менять пять первых строк и диаграмму. При показе разреза и смене валюты «Прочее» SHALL быть свёрнуто.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
#### Scenario: Раскрытие «Прочее»
- **GIVEN** показан разрез «Акции» из семи акций (400, 250, 150, 100, 50, 30, 20), «Прочее» свёрнуто
- **WHEN** User нажимает на строку «Прочее»
- **THEN** видны две акции с суммами 30 ₽ и 20 ₽ и долями 3 % и 2 %, пять первых строк прежние
#### Scenario: Повторное нажатие
- **GIVEN** «Прочее» раскрыто
- **WHEN** User нажимает на него ещё раз
- **THEN** список остальных элементов скрыт
#### Scenario: Смена валюты сворачивает «Прочее»
- **GIVEN** «Прочее» раскрыто в RUB
- **WHEN** User выбирает USD
- **THEN** «Прочее» свёрнуто
### Requirement: Порядок элементов с равной суммой
Элементы разреза с равной суммой SHALL упорядочиваться по названию по возрастанию без учёта регистра: сначала названия, начинающиеся с цифры, затем с латинской буквы, затем с кириллической; внутри группы — по алфавиту, при совпадении названия — по порядку первой позиции элемента в Portfolio.
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
#### Scenario: Равные суммы на границе пятой строки
- **GIVEN** в RUB семь акций: 400, 300, 200, 150, «Альфа» 100, «Бета» 100 и 50
- **WHEN** User открывает разрез «Акции»
- **THEN** «Альфа» пятая строка, а «Бета» и акция на 50 входят в «Прочее»
#### Scenario: Цифры, латиница и кириллица при равных суммах
- **GIVEN** в RUB три акции с равной суммой 100 и названиями «Сбер», «VK» и «X5»
- **WHEN** User открывает разрез «Акции»
- **THEN** порядок строк: «VK», «X5», «Сбер»
### Requirement: Расчёт долей
Точная доля элемента — сумма его учитываемых позиций, делённая на сумму всех учитываемых позиций разреза в выбранной валюте; Cash Balance в расчёте не участвует. Точные доли элементов SHALL давать в сумме ровно 100 %, точная доля «Прочее» — равняться сумме точных долей остальных элементов, суммы — быть без округления.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
#### Scenario: Доля без денежных остатков
- **GIVEN** акции на 600 RUB и 400 RUB и Cash Balance 1 000 RUB
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** доли 60 % и 40 %
#### Scenario: Доля «Прочее»
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
- **THEN** доля «Прочее» 5 % равна сумме долей 3 % и 2 %
### Requirement: Показ долей
Доля SHALL показываться, как в карточке позиции: округление до десятых в русской локали, середина — вверх (6,25 % — «6,3 %»), без «,0» («40 %», «66,7 %»). Положительная доля меньше 0,1 % показывается как «< 0,1 %». Суммарная доля топ-5 — в том же формате; сумма показанных долей может не быть 100 %.
**Источник (ненормативно):** [3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079), [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
#### Scenario: Три равные доли
- **GIVEN** три акции «А», «Б» и «В» по 100 RUB
- **WHEN** User открывает разрез «Акции»
- **THEN** каждая доля показана как «33,3 %»
#### Scenario: Округление, сумма показанных долей не равна 100 %
- **GIVEN** две акции на 1 RUB и 15 RUB, точные доли 6,25 % и 93,75 %
- **WHEN** User открывает разрез «Акции»
- **THEN** доли показаны как «93,8 %» и «6,3 %», хотя в сумме дают 100,1 %
#### Scenario: Очень малая доля
- **GIVEN** акции на 100 000 000 RUB и 1 RUB
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** доли показаны как «100 %» и «< 0,1 %», сумма второй акции остаётся 1 ₽
### Requirement: Суммарная доля топ-5
Система SHALL показывать метрику «доля топ-5» — сумму точных долей пяти первых по сумме элементов разреза, в формате требования «Показ долей» — только когда элементов шесть и более. При пяти и менее элементах метрика SHALL NOT показываться.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
#### Scenario: Семь элементов
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
- **WHEN** User открывает разрез «Акции»
- **THEN** доли строк 40 %, 25 %, 15 %, 10 %, 5 % и «Прочее» 5 %, доля топ-5 равна 95 %
#### Scenario: Ровно шесть элементов
- **GIVEN** в RUB шесть акций с суммами 300, 250, 200, 150, 60 и 40
- **WHEN** User открывает разрез «Акции»
- **THEN** доля топ-5 равна 96 %, а шестая строка показана с долей 4 %
#### Scenario: Ровно пять элементов
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
- **WHEN** User открывает разрез «Акции»
- **THEN** метрика «доля топ-5» не показывается
#### Scenario: Один элемент
- **GIVEN** в RUB одна акция
- **WHEN** User открывает разрез «Акции»
- **THEN** метрика «доля топ-5» не показывается
### Requirement: Группы «Сектор не указан» и «Эмитент не указан»
Система SHALL объединять учитываемые позиции с `sector: null` («Секторы») в элемент «Сектор не указан», а с `issuer: null` («Эмитенты») — в элемент «Эмитент не указан». Эти элементы SHALL ранжироваться и попадать в «Прочее» наравне с остальными, а их название SHALL быть видно в списке и диаграмме. Сектор и эмитент SHALL NOT выводиться из названия.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
#### Scenario: Позиции без эмитента в топе
- **GIVEN** в RUB акции эмитента «Сбер» на 300, эмитента «Газпром» на 300 и без эмитента на 200 и 100
- **WHEN** User открывает разрез «Эмитенты» в RUB
- **THEN** по убыванию: «Газпром» 300, «Сбер» 300, «Эмитент не указан» 300 (две позиции), группа ранжируется наравне с другими
#### Scenario: Группа без сектора в «Прочее»
- **GIVEN** у шести секторов суммы 600, 500, 400, 300, 200 и 100, у позиций без сектора сумма 50
- **WHEN** User раскрывает «Прочее» в разрезе «Секторы»
- **THEN** «Прочее» содержит шестой сектор 100 ₽ и «Сектор не указан» 50 ₽
### Requirement: Подписи секторов
Система SHALL показывать известное значение `sector` русской подписью по требованию «Русские подписи известных секторов», неизвестное — как получено, без перевода и пояснений, в списке и текстовом эквиваленте диаграммы. Система SHALL NOT придумывать подписи сверх таблицы и собственную классификацию и SHALL NOT объединять позиции с разными `sector`.
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
#### Scenario: Неизвестное значение сектора
- **GIVEN** облигация имеет `sector` `other`, которого нет в таблице подписей
- **WHEN** User открывает разрез «Секторы»
- **THEN** элемент назван `other`
### Requirement: Русские подписи известных секторов
Система SHALL показывать `sector` подписью по таблице.
| Значение | Подпись |
| --- | --- |
| `consumer` | Потребительский сектор |
| `energy` | Энергетика |
| `financial` | Финансы |
| `health_care` | Здравоохранение |
| `industrials` | Промышленность |
| `it` | Информационные технологии |
| `materials` | Материалы |
| `real_estate` | Недвижимость |
| `telecom` | Телекоммуникации |
| `utilities` | Коммунальные услуги |
| `government` | Государственный сектор |
#### Scenario: Известный сектор
- **GIVEN** позиция имеет `sector` `it`
- **WHEN** User открывает разрез «Секторы»
- **THEN** элемент назван «Информационные технологии»
#### Scenario: Государственные облигации
- **GIVEN** облигация имеет `sector` `government`
- **WHEN** User открывает разрез «Секторы»
- **THEN** элемент назван «Государственный сектор»
### Requirement: Диаграмма распределения разреза
Система SHALL показывать диаграмму разреза с теми же элементами, в том же порядке и с теми же долями, что в списке: для разреза не более чем из шести элементов — сегмент на каждый элемент, для разреза из семи и более — пять сегментов и один сегмент «Прочее», одинаково для свёрнутого и раскрытого списка. Для пустого разреза диаграмма SHALL NOT показываться. Не учтённые позиции SHALL NOT входить в диаграмму.
#### Scenario: Семь элементов
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20 RUB
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
- **THEN** диаграмма состоит из шести сегментов с долями 40 %, 25 %, 15 %, 10 %, 5 %, 5 % в порядке списка, как и при свёрнутом «Прочее»
#### Scenario: Ровно шесть элементов
- **GIVEN** шесть акций с суммами 300, 250, 200, 150, 60 и 40 RUB
- **WHEN** User открывает разрез «Акции»
- **THEN** диаграмма состоит из шести сегментов с долями 30 %, 25 %, 20 %, 15 %, 6 %, 4 %
### Requirement: Центр диаграммы
Система SHALL показывать в центре диаграммы сумму разреза — точную сумму учитываемых позиций в выбранной валюте, а над ней, когда показывается метрика «доля топ-5», подпись «Топ-5 · N %», где N — доля в формате «Показ долей». Без метрики показывается только сумма. Эти значения SHALL входить в текстовый эквивалент диаграммы.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
#### Scenario: Центр при семи акциях
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
- **WHEN** User открывает разрез «Акции»
- **THEN** в центре «Топ-5 · 95 %» и «1 000 ₽»
#### Scenario: Центр при пяти акциях
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
- **WHEN** User открывает разрез «Акции»
- **THEN** в центре только «1 000 ₽»
### Requirement: Строки без переходов
Строка списка в любом разрезе SHALL NOT вести на другую страницу; единственное действие строки — раскрытие и сворачивание «Прочее». Строки разреза «Эмитенты» SHALL NOT содержать логотипов.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
#### Scenario: Нажатие на строку акции
- **WHEN** User нажимает на строку акции в разрезе «Акции»
- **THEN** страница не меняется
#### Scenario: Строка эмитента
- **WHEN** User открывает разрез «Эмитенты»
- **THEN** строки показаны без логотипов
### Requirement: Доступность диаграммы без цвета
Информация диаграммы SHALL NOT передаваться только цветом: каждый сегмент SHALL быть сопоставим со строкой списка по названию и доле, доступным как текст. Диаграмма SHALL иметь текстовый эквивалент с названиями, суммами и долями сегментов в порядке списка, доступный вспомогательным технологиям.
#### Scenario: Сегменты различимы без цвета
- **GIVEN** показан разрез из шести строк
- **WHEN** User читает диаграмму вспомогательной технологией
- **THEN** доступны название, сумма и доля каждого сегмента в порядке списка, сопоставление со строкой не требует различения цветов
### Requirement: Пустое состояние разреза
Если выбранный разрез не содержит учитываемых элементов, система SHALL показывать пустое состояние с названием разреза и выбранной валютой (если валюты доступны) вместо списка, суммарной доли и диаграммы. Выбор валюты, если он показывается, и сноска при N больше нуля SHALL оставаться доступными.
#### Scenario: Нет облигаций в выбранной валюте
- **GIVEN** есть акции в RUB и облигации только в USD
- **WHEN** User открывает разрез «Облигации» в RUB
- **THEN** показано пустое состояние, выбор валюты доступен (валют две)
#### Scenario: Все позиции разреза отрицательные
- **GIVEN** в RUB есть только одна акция со стоимостью -50
- **WHEN** User открывает разрез «Акции» в RUB
- **THEN** показано пустое состояние и сноска «Не учтено позиций: 1»
### Requirement: Предупреждение о неполных данных
Если `meta` обозначает неполноту по «Результат личного обновления остаётся наблюдаемым» из `tbank-data-sync`, вкладка SHALL показывать предупреждение «Данные неполные» над разрезом, считая разрезы по доступным данным. Страница SHALL NOT дублировать его; позиции без оценки его не вызывают.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
#### Scenario: Неполный Portfolio
- **GIVEN** `meta` обозначает неполноту, акции стоят 600 и 400 RUB
- **WHEN** User открывает разрез «Акции»
- **THEN** показаны предупреждение «Данные неполные» и доли 60 % и 40 %
#### Scenario: Предупреждение не дублируется
- **GIVEN** `meta` обозначает неполноту
- **WHEN** User открывает вкладку «Концентрация»
- **THEN** на странице одно сообщение о неполноте — «Данные неполные» на вкладке
#### Scenario: Полный Portfolio с неоценённой позицией
- **GIVEN** Portfolio полный, одна акция без `currentValue`
- **WHEN** User открывает разрез «Акции»
- **THEN** предупреждения «Данные неполные» нет, есть сноска «Не учтено позиций: 1»
### Requirement: Состояния Portfolio на вкладке «Концентрация»
Загрузка, ошибка, устаревание и неполнота Portfolio на вкладке «Концентрация» SHALL соответствовать требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`, как на вкладке «Позиции». Отсутствие сектора или эмитента у позиций SHALL NOT скрывать вкладку и менять успешность и свежесть Portfolio.
#### Scenario: Устаревший Portfolio
- **GIVEN** клиент получает Portfolio с `isStale: true`
- **WHEN** User открывает вкладку «Концентрация»
- **THEN** разрезы рассчитаны по этому Portfolio, устаревание обозначено так же, как на вкладке «Позиции»
#### Scenario: Сектор и эмитент отсутствуют
- **GIVEN** у всех позиций `sector: null` и `issuer: null`, Portfolio успешен
- **WHEN** User открывает разрезы «Секторы» и «Эмитенты»
- **THEN** каждый разрез содержит один элемент «Сектор не указан» или «Эмитент не указан» с долей 100 %
@@ -0,0 +1,194 @@
## MODIFIED Requirements
### Requirement: Нормализованный состав Portfolio
Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Portfolio Position с типом `CURRENCY` MUST означать позицию по торгуемому валютному Instrument и MUST NOT смешиваться с Cash Balance. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio.
Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice`, nullable `absoluteReturn`, nullable `isin`, nullable `ticker` и nullable `logoName` — имя файла логотипа бренда инструмента, nullable `sector` — сектор инструмента и nullable `issuer` — эмитент позиции. Имя файла логотипа MUST заполняться из metadata инструмента, когда upstream предоставляет бренд с непустым именем файла логотипа; во всех остальных случаях оно MUST быть `null`. Логотип является presentation metadata: он не влияет на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot. Поле `sector` позиции типа `STOCK`, `BOND` или `FUND` MUST содержать значение сектора из metadata инструмента в том виде, как его определяет T-Bank, без перекодирования и собственной классификации, с обрезанными пробелами в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Поле `issuer` позиции типа `STOCK`, `BOND` или `FUND`, включая фонд, MUST быть объектом с непустыми строками `id` — идентификатор бренда актива инструмента — и `name` — название этого бренда (не название компании), когда T-Bank предоставляет для инструмента бренд актива с идентификатором и названием, непустыми после обрезки пробелов в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Система MUST NOT определять эмитента по совпадению названия, тикера или ISIN. Сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot: их отсутствие либо недоступность MUST NOT делать Snapshot неуспешным или устаревшим и MUST NOT скрывать позицию или её финансовые значения, кроме отказа token по требованию «Свежесть и fallback полного Portfolio Snapshot». Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения.
#### Scenario: Позиция с неизвестным upstream-типом
- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента
- **THEN** система возвращает её в исходном порядке с типом `OTHER`
#### Scenario: Позиция валютного инструмента не является денежным остатком
- **WHEN** upstream возвращает ненулевую Portfolio Position валютного инструмента и ненулевой Cash Balance той же валюты
- **THEN** система возвращает Portfolio Position с типом `CURRENCY` отдельно от Cash Balance и не объединяет их между собой
#### Scenario: Позиция без metadata инструмента
- **WHEN** lookup metadata по FIGI не дал ticker или name либо временно недоступен
- **THEN** система возвращает позицию с FIGI как fallback-идентификатором и всеми доступными финансовыми значениями
#### Scenario: Имя инструмента доступно
- **WHEN** metadata инструмента по FIGI содержит непустое name
- **THEN** система возвращает name как отображаемый идентификатор позиции вместо FIGI или ticker
#### Scenario: FIGI не разрешается, но доступен instrument UID
- **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name
- **THEN** система возвращает name, полученное по instrument UID
#### Scenario: ISIN инструмента доступен
- **WHEN** metadata инструмента позиции содержит непустой `isin`
- **THEN** система возвращает его в поле `isin` позиции
#### Scenario: ISIN инструмента отсутствует
- **WHEN** у инструмента позиции нет ISIN либо metadata недоступна
- **THEN** система возвращает позицию с `isin: null`, не скрывая финансовые значения
#### Scenario: Тикер инструмента доступен
- **WHEN** metadata инструмента позиции содержит непустой `ticker`
- **THEN** система возвращает его в поле `ticker` позиции
#### Scenario: Тикер инструмента отсутствует
- **WHEN** у инструмента позиции нет ticker либо metadata недоступна
- **THEN** система возвращает позицию с `ticker: null`, не скрывая финансовые значения
#### Scenario: Имя файла логотипа бренда доступно
- **WHEN** metadata инструмента позиции содержит бренд с непустым именем файла логотипа
- **THEN** система возвращает его в поле `logoName` позиции
#### Scenario: Имя файла логотипа бренда отсутствует
- **WHEN** у инструмента позиции нет бренда, имя файла логотипа пустое либо metadata недоступна
- **THEN** система возвращает позицию с `logoName: null`, не скрывая финансовые значения
#### Scenario: Денежные остатки в одной валюте
- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты
- **THEN** система возвращает один Cash Balance с их точной суммой
#### Scenario: Штатный пустой Portfolio
- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков
- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency`
#### Scenario: Сектор инструмента доступен
- **WHEN** metadata инструмента позиции содержит сектор `energy`
- **THEN** система возвращает в поле `sector` позиции значение `energy` без изменений
#### Scenario: Сектор инструмента отсутствует
- **WHEN** у инструмента позиции нет сектора, сектор пустой либо metadata недоступна
- **THEN** система возвращает позицию с `sector: null`, не скрывая финансовые значения
#### Scenario: Эмитент позиции доступен
- **WHEN** T-Bank предоставляет для инструмента позиции бренд актива с идентификатором `brand-1` и названием «Сбер»
- **THEN** система возвращает в поле `issuer` позиции идентификатор `brand-1` и название «Сбер»
#### Scenario: Название эмитента — название бренда
- **WHEN** T-Bank предоставляет для инструмента бренд актива с названием «Сбер» и компанией «ПАО Сбербанк»
- **THEN** система возвращает в `issuer.name` «Сбер»
#### Scenario: Эмитент у фонда
- **WHEN** T-Bank предоставляет для инструмента позиции `FUND` бренд актива с идентификатором `brand-2` и названием «Фонд»
- **THEN** система возвращает в `issuer` позиции идентификатор `brand-2` и название «Фонд»
#### Scenario: Пробелы по краям обрезаются
- **WHEN** T-Bank возвращает сектор " energy " и название бренда " Сбер "
- **THEN** система возвращает `sector` `energy` и `issuer.name` «Сбер»
#### Scenario: Значение из одних пробелов
- **WHEN** T-Bank возвращает сектор " " либо название бренда " "
- **THEN** система возвращает `sector: null` либо `issuer: null` соответственно
#### Scenario: Остальные типы позиций
- **WHEN** T-Bank предоставляет сектор и бренд актива для инструмента позиции типа `FUTURE`
- **THEN** система возвращает у этой позиции `sector: null` и `issuer: null`
#### Scenario: Эмитент позиции отсутствует
- **WHEN** у инструмента позиции нет бренда актива, идентификатор либо название бренда пустые или данные бренда недоступны
- **THEN** система возвращает позицию с `issuer: null`, не скрывая финансовые значения
#### Scenario: Эмитент не определяется по названию
- **WHEN** две позиции имеют одинаковое название, но разные идентификаторы бренда актива
- **THEN** система возвращает в `issuer` позиций разные идентификаторы бренда и не объединяет их
#### Scenario: Недоступность сектора и эмитента не нарушает Snapshot
- **GIVEN** сектор и бренд актива позиции ещё ни разу не получены
- **WHEN** получение сектора и бренда актива завершается ошибкой, а количества и цены позиции получены
- **THEN** система сохраняет Snapshot с `sector: null` и `issuer: null` у затронутых позиций; их `currentValue`, `totalsByCurrency`, `lastSuccessfulAt` и `isStale` такие же, как при доступных сектора и бренде
### Requirement: Свежесть и fallback полного Portfolio Snapshot
Система MUST хранить последний полный успешно полученный Portfolio Snapshot с принадлежностью User, экземпляру подключения и Brokerage Account. Обычный GET MUST возвращать `{ data, meta }`, где `data` является сохранённым Snapshot, а `meta` соответствует наблюдаемому состоянию resource по `tbank-data-sync`, читать его без ожидания T-Bank и MUST NOT запускать обновление личного Portfolio, даже если Snapshot отсутствует или старше 60 минут; обновлением владеет часовой обход. Portfolio source MUST считаться свежим в течение 60 минут после успешного получения. Ошибка обхода MUST сохранять прежний полный Snapshot и его время без лимита fallback. Если состав получен полностью, но отдельная позиция не имеет цены, система MUST сохранить новый состав с неполной оценкой; metadata MUST NOT определять успешность или свежесть финансового Snapshot, кроме отказа token при получении сектора или бренда актива позиции: он MUST обрабатываться так же, как отказ token при обходе Portfolio (исключение, источник — [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)). Сохранённая доступная identity позиции MAY оставаться доступной при недоступности online-справочника.
Система MUST делать прежние личные datasets недоступными после успешной замены или удаления подключения. Поздняя работа прежнего подключения MUST NOT публиковаться в новом. Самостоятельные справочные сведения и результаты lookup MUST запрашиваться онлайн, а не сохраняться как долговременный справочник; ошибка lookup не подтверждает отсутствие инструмента. Исключение: сектор и бренд актива позиции MUST храниться как последнее известное значение и обновляться по требованию «Обновление сектора и эмитента» ([#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)).
#### Scenario: Свежий Snapshot
- **WHEN** User повторно запрашивает тот же Portfolio в течение 60 минут после успешного получения
- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank
#### Scenario: Медленный lookup инструмента
- **WHEN** сведения об инструменте ещё запрашиваются онлайн
- **THEN** система не задерживает локальный ответ Portfolio ради lookup и показывает сохранённую доступную identity
#### Scenario: Fallback после временной upstream-ошибки
- **WHEN** часовой обход Portfolio неуспешен из-за временной недоступности T-Bank, а в текущей границе подключения есть полный успешный Snapshot
- **THEN** система отвечает `200` этим Snapshot с `isStale: true` и исходным `lastSuccessfulAt`
#### Scenario: Недействительный token не маскируется fallback-данными
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при часовом обходе Portfolio
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`
#### Scenario: Неполная оценка при новом составе
- **WHEN** успешно получен полный состав позиций, но у одной позиции нет вычислимой цены
- **THEN** новый состав становится текущим Portfolio Snapshot с неизвестной стоимостью позиции без подстановки нуля
#### Scenario: Сектор и бренд читаются из хранимого значения
- **GIVEN** сектор позиции получен 10 часов назад
- **WHEN** User запрашивает Portfolio
- **THEN** система возвращает хранимый `sector` без обращения к T-Bank за сектором
#### Scenario: Отказ token при получении бренда
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при получении сектора или бренда актива позиции
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`, как при отказе token в обходе Portfolio
## ADDED Requirements
### Requirement: Обновление сектора и эмитента
Система SHALL обновлять сектор и бренд актива позиции при первом обходе по `hourly-investment-sync`, начавшемся не ранее чем через 24 часа после последнего успешного получения; в остальных обходах значение не меняется. Пока значение не получено, получение повторяется при каждом обходе.
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
#### Scenario: Обход раньше 24 часов
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
- **WHEN** обход начинается во вторник в 08:59, то есть через 23 ч 59 мин
- **THEN** `issuer.name` остаётся прежним
#### Scenario: Обход через 24 часа
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
- **WHEN** обход начинается во вторник в 09:00
- **THEN** `issuer.name` новое
### Requirement: Результат получения сектора и эмитента
Результат получения SHALL определять поле позиции и отсчёт 24 часов по таблице; совпадает ровно одна строка.
| Исход получения | Поле | Отсчёт 24 часов |
| --- | --- | --- |
| Получено значение | Новое значение | Начинается заново |
| Ответ «не найдено» или значения нет | `null` | Начинается заново |
| Ответ о другом инструменте или активе | Прежнее значение или `null` | Не меняется; получение неуспешно |
| Иная ошибка, кроме отказа token | Прежнее значение или `null` | Не меняется; получение неуспешно |
Неуспешное получение SHALL NOT мешать обновлению остальных данных Portfolio; отказ token — по «Свежесть и fallback полного Portfolio Snapshot».
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
#### Scenario: Ответ без значения заменяет известное значение
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
- **WHEN** T-Bank отвечает, что сектора у инструмента нет
- **THEN** `sector: null`
#### Scenario: «Не найдено» перезапускает отсчёт
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли, и обход получает от T-Bank «не найдено»
- **WHEN** следующий обход начинается через час, а T-Bank уже возвращает сектор `financial`
- **THEN** `sector` остаётся `null`; значение `financial` появляется при первом обходе, начавшемся не ранее чем через 24 часа после ответа «не найдено»
#### Scenario: Ответ о другом инструменте
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
- **WHEN** T-Bank возвращает данные другого инструмента
- **THEN** `sector` остаётся `energy`, а следующий обход повторяет получение
#### Scenario: Недоступность T-Bank сохраняет известное значение
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
- **WHEN** получение сектора завершается недоступностью T-Bank, а цены и количества получены
- **THEN** позиция возвращается с `sector` `energy`, остальные данные Portfolio обновлены, `lastSuccessfulAt` новый
#### Scenario: Значение ещё не известно
- **GIVEN** новая позиция, сектор которой ни разу не получен успешно
- **WHEN** получение сектора неуспешно при одном обходе и успешно при следующем
- **THEN** после первого обхода `sector: null`, после следующего значение сектора
@@ -0,0 +1,187 @@
# Tasks
Issue-координатор: [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). В каждом affected repo ветка `feature/48-account-top-positions` создаётся от актуального `develop`; PR открывается в `develop`. Тело PR: Summary, Linked Issue `WealthLedger/main#48`, Changes, Verification, ссылки на связанные PR других repo — по `.ai/conventions/engineering-workflow.md` («Analysis → Branch», «Validation → PR»). Обозначения **PC**, **PVΔ**: PC — `portfolio-concentration`, PVΔ — delta `portfolio-view`.
## 1. Backend: хранение, классификация, DTO, OpenAPI
- [x] 1.1 [backend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
- **Covers:** N/A — организационный шаг cross-repo delivery
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch», «Branch → Implementation»
- **Verification:** `git -C backend branch --show-current` выводит `feature/48-account-top-positions`; `git log origin/develop..HEAD` пуст до первой задачи
- **Depends on:** none
- [x] 1.2 [backend] Migration: аддитивная модель `TbankInstrumentClassification` и миграция `CREATE TABLE`
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
- **Source:** design.md «Data / Storage Changes», B1; «Migration / Rollout / Rollback»
- **Verification:** `npx prisma validate`; `npx prisma generate`; `npm run typecheck:app`; миграция применена к disposable копии БД с существующими Snapshot, прежние таблицы и строки не изменились; `DROP TABLE "TbankInstrumentClassification"` на копии возвращает её к исходной схеме
- **Depends on:** 1.1
- [x] 1.3 [backend] Implementation: `TbankClient.getEtf`, `getAssetBrand` и opt-in NOT_FOUND для новых вызовов классификации
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`
- **Source:** design.md B4 «Новые методы», «NOT_FOUND»; analysis/data-mapping.md «Источники-кандидаты в T-Bank»
- **Verification:** `npm test -- src/integrations/tbank/tbank.client.spec.ts`: NOT_FOUND даёт `NotFoundException` для `bondBy`/`etfBy`/`getAssetBy` только при opt-in; прежние `getBond`/`getAssetDescription` по-прежнему дают Unavailable; `PERMISSION_DENIED` даёт `InvalidToken`
- **Depends on:** 1.1
- [x] 1.4 [backend] Implementation: `PositionClassificationService` — правило 24 ч, четыре исхода по каждому полю, UID→FIGI после NOT_FOUND, дедупликация по инструменту и `asset_uid`
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** design.md B3, B4, B5; analysis/failure-model.md «Классификация отказов по исходам PVΔ «Результат получения сектора и эмитента»», F02–F12
- **Verification:** `npm test -- src/modules/investments/portfolio`: границы 23:59 и 24:00 от `walkStartedAt`; при отсутствии значения получение идёт в каждом обходе; 4 исхода × {sector, brand}; mismatch не меняет поле; повтор по FIGI после NOT_FOUND по UID; один `GetAssetBy` на общий `asset_uid`; используется сохранённый `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет; типы кроме STOCK/BOND/FUND не запрашиваются
- **Depends on:** 1.2, 1.3
- [x] 1.5 [backend] Implementation: шаг классификации в `PortfolioService.queueSnapshot` до publish; `walkStartedAt` из `TbankHourlyScheduler`; mapper пишет `sector`/`issuer`
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** design.md B2, B3; analysis/failure-model.md F01, F08
- **Verification:** `npm test -- src/modules/investments/portfolio/application/portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован, вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован, `lastSuccessfulAt` и `isStale` не изменены, поля — прежние известные; ошибка GetPortfolio → публикации нет независимо от классификации
- **Depends on:** 1.4
- [x] 1.6 [backend] Implementation: DTO `sector` и `PortfolioIssuerDto` (nullable), нормализация старых Snapshot в `saved()`
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** design.md B6, B7; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`», «Совместимость и миграция»
- **Verification:** `npm run test:e2e`: GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio` возвращают у каждой позиции `sector` и `issuer`; для payload без этих полей — `null`; после отказа token — `400 INVALID_TBANK_TOKEN`
- **Depends on:** 1.5
- [x] 1.7 [backend] Contract Parity: OpenAPI-artifact с новыми полями совпадает с target-delta
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** design.md «Contract Implementation»; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`»
- **Verification:** `npm run openapi:generate` и `npm run test:openapi-contract` проходят; в корневом `openapi.json` у `PortfolioPositionDto` поля `sector` (string, nullable, required) и `issuer` (nullable, required, `id`/`name` обязательные строки); версия OpenAPI 3.0.x; запросы, маршруты и статусы не изменились (diff схемы с `frontend/src/shared/api/spec/openapi.json` содержит только эти добавления)
- **Depends on:** 1.6
- [x] 1.8 [backend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48
- **Covers:** N/A — финальный gate repo
- **Source:** backend `.ai/conventions/engineering.md` «Проверки и завершение»; `.ai/conventions/engineering-workflow.md` «Validation → PR»
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт, в теле `WealthLedger/main#48` и OpenAPI-artifact приложен как вход для frontend
- **Depends on:** 1.7
## 2. Frontend: контракт, модель, UI
- [x] 2.1 [frontend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
- **Covers:** N/A — организационный шаг cross-repo delivery
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»
- **Verification:** `git -C frontend branch --show-current` выводит `feature/48-account-top-positions`
- **Depends on:** none
- [x] 2.2 [frontend] Implementation: предоставленный backend snapshot в `src/shared/api/spec/openapi.json` и generated `schema.d.ts`
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** design.md B6; frontend `.ai/conventions/data.md` «Типы и contract»
- **Verification:** `npm run openapi:generate`; `npm run openapi:check` без diff; `npm run typecheck`; generated `paths` содержит `sector` и `issuer` у позиции
- **Depends on:** 1.7, 2.1
- [x] 2.3 [frontend] Implementation: `pages/account/model/concentration.ts` — состав разрезов, учитываемые позиции, валюты, точные суммы и доли, топ-5, «Прочее», порядок равных, имя эмитента, группы «не указан»
- **Covers:** `portfolio-concentration` — `Состав разрезов`; `portfolio-concentration` — `Названия элементов`; `portfolio-concentration` — `Название эмитента при разных названиях бренда`; `portfolio-concentration` — `Позиции, учитываемые в разрезе`; `portfolio-concentration` — `Сноска о не учтённых позициях`; `portfolio-concentration` — `Одна валюта за раз`; `portfolio-concentration` — `Доступные валюты вкладки`; `portfolio-concentration` — `Топ-5 и строка «Прочее»`; `portfolio-concentration` — `Порядок элементов с равной суммой`; `portfolio-concentration` — `Расчёт долей`; `portfolio-concentration` — `Показ долей`; `portfolio-concentration` — `Суммарная доля топ-5`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
- **Source:** design.md F1; analysis/data-mapping.md M3, I1–I8; analysis/test-cases/conc.md
- **Verification:** `npm run test:run -- src/pages/account/model/concentration.test.ts` по oracle TC-CONC-007, TC-CONC-008, TC-CONC-016, TC-CONC-020; сумма точных долей = 100 %; 6,25 → «6,3 %»; «< 0,1 %»; FUND исключён из «Эмитентов»
- **Depends on:** 2.2
- [x] 2.4 [frontend] Implementation: state `{accountId, tab, slice, currency}` в `AccountPage.vm.ts`, управляемые `Tabs`, вкладка только для одного счёта
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-concentration` — `Вкладка в состояниях страницы`; `portfolio-concentration` — `Переключение разрезов`; `portfolio-concentration` — `Начальное состояние вкладки`; `portfolio-concentration` — `Независимый выбор валюты вкладки`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
- **Source:** design.md F2, F3; analysis/test-cases/conc.md TC-CONC-002, TC-CONC-005, TC-CONC-006, TC-CONC-015
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: вкладки нет для `all`, при загрузке, ошибке и пустом Portfolio; по умолчанию открыта «Позиции»; разрез и валюта сохраняются при переключении вкладок и сбрасываются при смене счёта; при исчезновении валюты выбирается первая
- **Depends on:** 2.3
- [x] 2.5 [frontend] Implementation: `Concentration.view` — список, раскрытие «Прочее», пустое состояние, сноска, выбор валюты, Alert «Данные неполные», подавление страничного Alert на вкладке
- **Covers:** `portfolio-concentration` — `Раскрытие строки «Прочее»`; `portfolio-concentration` — `Строки без переходов`; `portfolio-concentration` — `Пустое состояние разреза`; `portfolio-concentration` — `Предупреждение о неполных данных`
- **Source:** design.md F2, F4, «UI Implementation»; analysis/ux-design.md «UI States», «Responsive Behavior»; visuals v2 (SHA-256 по ux-design «Materials» сверены до начала)
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: при `partial` на «Концентрации» одно сообщение «Данные неполные», на других вкладках — прежний Alert; строки не являются ссылками; browser-тест 320 px по образцу `position-layout.browser.test.tsx` без горизонтального скролла страницы
- **Depends on:** 2.4
- [x] 2.6 [frontend] Implementation: DonutChart — центр-оверлей, обводка и штриховка через `cellProps`, цифры ранга, текстовый эквивалент
- **Covers:** `portfolio-concentration` — `Диаграмма распределения разреза`; `portfolio-concentration` — `Центр диаграммы`; `portfolio-concentration` — `Доступность диаграммы без цвета`
- **Source:** design.md F5; analysis/ux-design.md «Accessibility», D7; analysis/test-cases/conc.md TC-CONC-027
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: 7 элементов → 6 сегментов в порядке списка; центр «Топ-5 · 95 %» и «1 000 ₽»; при ≤ 5 элементах только сумма; `role="img"` и `aria-describedby` с названиями, суммами и долями
- **Depends on:** 2.5
- [x] 2.7 [frontend] Implementation: подписи секторов через общий словарь `stock.sectors` + ключ `government` («Государственный сектор»), видимый и в каталогах
- **Covers:** `portfolio-concentration` — `Подписи секторов`; `portfolio-concentration` — `Русские подписи известных секторов`
- **Source:** design.md F6; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); analysis/test-cases/conc.md TC-CONC-024
- **Verification:** `npm run test:run`: 11 кодов из таблицы PC дают русские подписи; `other` выводится как есть; тест `StockCompany` (страница акции): `sector: "government"` отображается как «Сектор: Государственный сектор» — owner-approved изменение вне Specs (design.md F6, [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084))
- **Depends on:** 2.3
- [x] 2.8 [frontend] Automated Testing: регрессия вкладки «Позиции» и «Структуры портфеля»
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`
- **Source:** analysis/qa-analysis.md «Regression Impact»; analysis/test-cases/reg.md TC-REG-001
- **Verification:** существующие `AccountPage.test.tsx`, `position-groups.test.tsx`, тесты `widgets/portfolio-structure` проходят без изменения ожиданий
- **Depends on:** 2.6, 2.7
- [x] 2.9 [frontend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48 и backend PR
- **Covers:** N/A — финальный gate repo
- **Source:** frontend `.ai/conventions/engineering.md`; `.ai/conventions/engineering-workflow.md` «Validation → PR»
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт со ссылками на `WealthLedger/main#48` и backend PR
- **Depends on:** 2.8
## 3. Review и QA до G4
- [x] 3.1 ~~[specs] Independent Review: read-only review backend и frontend PR против Specs, design и analysis~~ (descoped, см. process-record)
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-concentration` — `Расчёт долей`
- **Source:** design.md целиком; analysis/system-analysis.md; analysis/failure-model.md
- **Verification:** отчёт review приложен к Gate Log G4 в process-record; blocker/major закрыты или возвращены в upstream-артефакт
- **Depends on:** 1.8, 2.9
- [x] 3.2 ~~[specs] QA Verification: прогон test-cases на локальном backend + frontend~~ (descoped, см. process-record)
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
- **Source:** analysis/qa-analysis.md «Coverage Matrix», «Entry / Exit Criteria»; analysis/test-cases/conc.md, meta.md, reg.md
- **Verification:** результаты TC-CONC-002/005/006/007/008/015/016/020/024/027, TC-META-002/003/004/005, TC-REG-001/004 записаны pass/fail; F01–F16 покрыты; ручной блок (макеты v2, контраст, клавиатура, скринридер) зафиксирован
- **Depends on:** 1.8, 2.9
- [x] 3.3 ~~[specs] QA Verification: exploratory Q-D1/Q-D2/Q-F3 на реальном токене владельца~~ (descoped, см. process-record)
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
- **Source:** analysis/qa-analysis.md «Gaps / Blockers», «Test Data and Environment Needs»; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)
- **Verification:** записаны полнота сектора и бренда у позиций владельца (доля стоимости без значения, наличие `government` и фондов без сектора), число RPC и длительность первого обхода, отсутствие RESOURCE_EXHAUSTED / queue full; шаг Portfolio укладывается в `WORK_WAIT_BOUND_MS`
- **Depends on:** 3.2
- [x] 3.4 ~~[backend] Verification: откат backend-bundle на копии данных до G4~~ (descoped, см. process-record)
- **Covers:** N/A — проверка отката (design.md «Migration / Rollout / Rollback»)
- **Source:** design.md «Migration / Rollout / Rollback» (Backend); `deploy/README.md` «Ручной откат и восстановление»; `docs/architecture/deployment.md` «Откат image»
- **Verification:** на disposable копии БД с применённой миграцией `TbankInstrumentClassification`: (1) предыдущий backend-image, запущенный как при ручном откате (`up --no-deps backend`, без сервиса `migration`), проходит healthcheck, GET Portfolio отвечает `200` на Snapshot с полями `sector`/`issuer`; (2) из предыдущего image `node node_modules/prisma/build/index.js migrate status` и `migrate deploy` — записаны exit code и вывод (ожидание: status сообщает о миграции, отсутствующей локально; deploy не падает). Результат записан в Gate Log G4; при падении deploy — finding в design
- **Depends on:** 1.8
## 4. Docs (по analysis/docs-impact.md, после merge кода в `develop`)
- [x] 4.1 [docs] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
- **Covers:** N/A — организационный шаг cross-repo delivery
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»; analysis/docs-impact.md «Summary» (правки AS-IS только после merge кода)
- **Verification:** backend и frontend PR слиты в `develop`; `git -C docs branch --show-current` выводит `feature/48-account-top-positions`
- **Depends on:** 1.8, 2.9, 3.3
- [x] 4.2 [docs] Docs: домен Portfolio — `overview.md`, при необходимости `concentration.md`, `data-reference.md`, `flows/portfolio-loading.md`, условная строка в `all-accounts.md`
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-view` — `Нормализованный состав Portfolio`
- **Source:** analysis/docs-impact.md «Documentation Changes» (строки домена и flow)
- **Verification:** `npm run verify` в docs; текст соответствует реализованному AS-IS
- **Depends on:** 4.1
- [x] 4.3 [docs] Docs: интеграция и архитектура — `integrations/tbank.md`, `architecture/cross-cutting/data-freshness.md`, `architecture/containers.md`
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Обновление сектора и эмитента`
- **Source:** analysis/docs-impact.md «Documentation Changes» (Integration, Architecture); design.md B1, B4
- **Verification:** `npm run verify` в docs
- **Depends on:** 4.1
- [x] 4.4 [docs] Docs: ADR 022 `stored-instrument-classification` (Accepted, AS-IS), строка в `adr/README.md`, ссылка в Status ADR 019
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
- **Source:** analysis/docs-impact.md «ADR Decision»; design.md «ADR Candidates», «Solution Assessment»; решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)
- **Verification:** `npm run verify` в docs; текст ADR 019 кроме строки Status не изменён
- **Depends on:** 4.1
- [x] 4.5 [docs] Docs: глоссарий и навигация — `glossary.md`, `overview.md`, `README.md`
- **Covers:** `portfolio-concentration` — `Переключение разрезов`
- **Source:** analysis/docs-impact.md «Glossary / Navigation Impact»
- **Verification:** `npm run verify` в docs; ссылки из навигации ведут на домен Portfolio
- **Depends on:** 4.2
- [x] 4.6 [docs] Verification: PR в `develop` со ссылкой на WealthLedger/main#48 и code PR
- **Covers:** N/A — финальный gate repo
- **Source:** `.ai/conventions/engineering-workflow.md` «Validation → PR»
- **Verification:** `npm run verify` зелёный; PR открыт со ссылками на `WealthLedger/main#48`, backend и frontend PR
- **Depends on:** 4.2, 4.3, 4.4, 4.5
## 5. Specs: G4 и завершение change
- [x] 5.1 ~~[specs] Verification: запись G4 в process-record с evidence задач 1–4~~ (descoped, см. process-record)
- **Covers:** N/A — gate протокол
- **Source:** instruction `process-record` (Readiness Checklist, Gate Log)
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 validate account-top-positions --store wealth-ledger-specs --strict` valid; `git diff --check` чист; строка G4 содержит ссылки на PR, отчёты 3.1–3.3 и решение владельца
- **Depends on:** 3.1, 3.2, 3.3, 3.4, 4.6
- [x] 5.2 [specs] Verification: native archive change после G4 и PR в `develop` со ссылкой на WealthLedger/main#48
- **Covers:** N/A — native sync/archive выполняет CLI, canonical specs вручную не правятся
- **Source:** schema `wealth-ledger-sdd-lab` apply/archive instruction; `.ai/conventions/engineering-workflow.md` «Merge → Issue Closed»
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 archive account-top-positions --store wealth-ledger-specs` завершён; `openspec validate --all --strict` valid; PR specs открыт; Issue main#48 закрывается после merge всех PR
- **Depends on:** 5.1
@@ -0,0 +1,93 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" role="img">
<title>Концентрация — desktop — разрез Эмитенты, одна валюта, предупреждение</title>
<desc>Макет вкладки Концентрация, разрез Эмитенты, валюта RUB. Центр: Топ-5 · 94,2 %, 3 527 000 ₽. Сбер 31,8 %; Газпром 22,1 %; ЛУКОЙЛ 18,1 %; Эмитент не указан 11,6 %; Яндекс 10,5 %; РЖД 5,8 %; предупреждение «Данные неполные»</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="1280" height="800" rx="0" fill="#e9ecef" />
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
<rect x="120" y="120" width="1040" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="120" y="120" width="4" height="48" rx="2" fill="#2563eb" /><text x="140" y="149" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
<rect x="120" y="184" width="1040" height="568" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="144" y="208" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<text x="204.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
<text x="317.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
<text x="430.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
<rect x="487.0" y="212" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="543.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
<rect x="144" y="272" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 330.00 A110 110 0 0 1 392.24 485.30 L363.08 472.12 A78 78 0 0 0 292.00 362.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="396.2" y="376.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
<path d="M392.24 485.30 A110 110 0 0 1 265.51 546.76 L273.22 515.71 A78 78 0 0 0 363.08 472.12 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="346.1" y="555.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
<path d="M265.51 546.76 A110 110 0 0 1 183.93 460.50 L215.37 454.54 A78 78 0 0 0 273.22 515.71 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="201.9" y="529.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="url(#hatch)"/>
<text x="170.0" y="422.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
<path d="M197.82 383.17 A110 110 0 0 1 252.72 337.25 L264.14 367.14 A78 78 0 0 0 225.22 399.70 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="212.4" y="348.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
<path d="M252.72 337.25 A110 110 0 0 1 292.00 330.00 L292.00 362.00 A78 78 0 0 0 264.14 367.14 Z" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="269.5" y="322.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">6</text>
<text x="292.0" y="434" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 94,2 %</text>
<text x="292.0" y="462" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 527 000 ₽</text></g>
<text x="292.0" y="602" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
<text x="476" y="305" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
<circle cx="506" cy="300" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбер</text>
<text x="1136" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">31,8 %</text>
<rect x="528" y="326" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="326" width="193.07059824213212" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="346" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="381" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
<circle cx="506" cy="376" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
<text x="1136" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,1 %</text>
<rect x="528" y="402" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="402" width="134.45988091862773" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="422" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="457" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
<circle cx="506" cy="452" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
<text x="1136" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">18,1 %</text>
<rect x="528" y="478" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="478" width="110.32605613836121" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="498" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="533" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
<circle cx="506" cy="528" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="528" r="5" fill="url(#hatch)"/>
<text x="528" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Эмитент не указан</text>
<text x="1136" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,6 %</text>
<rect x="528" y="554" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="url(#hatch)" />
<rect x="528" y="574" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="609" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
<circle cx="506" cy="604" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
<text x="1136" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
<text x="1136" y="620" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">10,5 %</text>
<rect x="528" y="630" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="630" width="64.12702013042247" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="650" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="685" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">6</text>
<circle cx="506" cy="680" r="5" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">РЖД</text>
<text x="1136" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">205 000 ₽</text>
<text x="1136" y="696" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">5,8 %</text>
<rect x="528" y="706" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="706" width="35.33881485681882" height="6" rx="3" fill="#2563eb" />
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="932" viewBox="0 0 1280 932" role="img">
<title>Концентрация — desktop — разрез Секторы, «Прочее» раскрыто, фокус на «Прочее»</title>
<desc>Макет вкладки Концентрация, разрез Секторы, валюта RUB. Центр: Топ-5 · 92,1 %, 4 079 330 ₽. Финансы 29,5 %; Государственный сектор 24,2 %; Энергетика 20,7 %; Информационные технологии 9,9 %; Сектор не указан 7,8 %; Прочее 7,9 %</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="1280" height="932" rx="0" fill="#e9ecef" />
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
<rect x="120" y="128" width="1040" height="756" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
<rect x="374.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Секторы</text>
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 397.58 414.87 L366.87 405.89 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="391.2" y="313.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
<path d="M397.58 414.87 A110 110 0 0 1 266.62 491.03 L274.01 459.90 A78 78 0 0 0 366.87 405.89 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="354.3" y="495.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
<path d="M266.62 491.03 A110 110 0 0 1 182.07 387.90 L214.05 386.76 A78 78 0 0 0 274.01 459.90 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="196.1" y="466.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
<path d="M182.07 387.90 A110 110 0 0 1 200.23 323.36 L226.92 341.00 A78 78 0 0 0 214.05 386.76 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="172.6" y="354.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="url(#hatch)"/>
<text x="208.2" y="296.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
<path d="M239.63 287.26 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 254.87 315.41 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 92,1 %</text>
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">4 079 330 ₽</text></g>
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Секторы · RUB</text>
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Финансы</text>
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 204 500 ₽</text>
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">29,5 %</text>
<rect x="528" y="270" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="270" width="179.52359823794592" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Государственный сектор</text>
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">986 300 ₽</text>
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">24,2 %</text>
<rect x="528" y="346" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="346" width="147.0021792794405" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Энергетика</text>
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">845 700 ₽</text>
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">20,7 %</text>
<rect x="528" y="422" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="422" width="126.046581178772" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="528" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Информационные технологии</text>
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">402 100 ₽</text>
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">9,9 %</text>
<rect x="528" y="498" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="498" width="59.93062586258038" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="548" r="5" fill="url(#hatch)"/>
<text x="528" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сектор не указан</text>
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">318 600 ₽</text>
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,8 %</text>
<rect x="528" y="574" width="608" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="url(#hatch)" />
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
<rect x="464" y="596" width="680" height="72" rx="10" fill="#eff6ff" />
<rect x="462" y="594" width="684" height="76" rx="12" fill="none" stroke="#2563eb" stroke-width="2" />
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">322 130 ₽</text>
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,9 %</text>
<path d="M1116 626 l6 -6 l6 6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="650" width="45.32671296511927" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
<rect x="506" y="666" width="2" height="176" rx="1" fill="#dee2e6" />
<text x="528" y="694" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">6</text>
<text x="552" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Потребительский сектор</text>
<text x="1102" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">164 200 ₽</text>
<text x="1102" y="712" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">4 %</text>
<rect x="552" y="720" width="550" height="4" rx="3" fill="#f1f3f5" />
<rect x="552" y="720" width="22.138439400587842" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
<text x="528" y="754" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">7</text>
<text x="552" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Телекоммуникации</text>
<text x="1102" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">96 410 ₽</text>
<text x="1102" y="772" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">2,4 %</text>
<rect x="552" y="780" width="550" height="4" rx="3" fill="#f1f3f5" />
<rect x="552" y="780" width="12.998580649273288" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
<text x="528" y="814" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">8</text>
<text x="552" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Материалы</text>
<text x="1102" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">61 520 ₽</text>
<text x="1102" y="832" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">1,5 %</text>
<rect x="552" y="840" width="550" height="4" rx="3" fill="#f1f3f5" />
<rect x="552" y="840" width="8.294499341803679" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="780" viewBox="0 0 1280 780" role="img">
<title>Концентрация — desktop — разрез Акции</title>
<desc>Макет вкладки Концентрация, разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="1280" height="780" rx="0" fill="#e9ecef" />
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
<rect x="120" y="128" width="1040" height="604" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<rect x="148.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 400.75 400.53 L369.11 395.72 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="386.0" y="307.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
<path d="M400.75 400.53 A110 110 0 0 1 294.14 493.98 L293.52 461.99 A78 78 0 0 0 369.11 395.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="373.7" y="481.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
<path d="M294.14 493.98 A110 110 0 0 1 197.93 441.02 L225.30 424.43 A78 78 0 0 0 293.52 461.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="232.2" y="496.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
<path d="M197.93 441.02 A110 110 0 0 1 184.15 362.36 L215.52 368.66 A78 78 0 0 0 225.30 424.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="169.9" y="409.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
<path d="M184.15 362.36 A110 110 0 0 1 209.70 311.02 L233.64 332.25 A78 78 0 0 0 215.52 368.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="181.0" y="332.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
<path d="M209.70 311.02 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 233.64 332.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
<rect x="528" y="224" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="548.0" y="249.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
<text x="580" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбербанк</text>
<text x="580" y="260" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
<rect x="580" y="270" width="556" height="6" rx="3" fill="#f1f3f5" />
<rect x="580" y="270" width="152.34525859947837" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
<rect x="528" y="300" width="40" height="40" rx="8" fill="#fff4e6" /><text x="548.0" y="325.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
<text x="580" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
<text x="580" y="336" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
<rect x="580" y="346" width="556" height="6" rx="3" fill="#f1f3f5" />
<rect x="580" y="346" width="123.93225100769021" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
<rect x="528" y="376" width="40" height="40" rx="8" fill="#ebfbee" /><text x="548.0" y="401.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
<text x="580" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
<text x="580" y="412" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
<rect x="580" y="422" width="556" height="6" rx="3" fill="#f1f3f5" />
<rect x="580" y="422" width="92.50457430960358" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
<rect x="528" y="452" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="548.0" y="477.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
<text x="580" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
<text x="580" y="488" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
<rect x="580" y="498" width="556" height="6" rx="3" fill="#f1f3f5" />
<rect x="580" y="498" width="65.7406802635876" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
<rect x="528" y="528" width="40" height="40" rx="8" fill="#fff3bf" /><text x="548.0" y="553.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
<text x="580" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Норникель</text>
<text x="580" y="564" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
<rect x="580" y="574" width="556" height="6" rx="3" fill="#f1f3f5" />
<rect x="580" y="574" width="46.675136781922866" height="6" rx="3" fill="#2563eb" />
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
<path d="M1116 620 l6 6 l6 -6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
<rect x="528" y="650" width="77.22374972598878" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
<text x="476" y="700" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="474" viewBox="0 0 390 474" role="img">
<title>Концентрация — mobile — пустой разрез Облигации</title>
<desc>Макет вкладки Концентрация (mobile 390), разрез Облигации, валюта RUB. пустое состояние</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="390" height="474" rx="0" fill="#e9ecef" />
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
<g clip-path="url(#tabclip)">
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
</g>
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
<rect x="16" y="116" width="358" height="326" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
<rect x="113.5" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Облигации</text>
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="28" y="240" width="334" height="130" rx="12" fill="#f8f9fa" stroke="#dee2e6" stroke-width="1" />
<text x="44" y="276" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="start">Облигации в RUB: нет позиций</text>
<text x="44" y="304" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">Нет позиций с положительной оценкой</text>
<text x="44" y="324" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">в этом разрезе и выбранной валюте.</text>
<text x="32" y="404" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 1</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,80 @@
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1020" viewBox="0 0 390 1020" role="img">
<title>Концентрация — mobile — разрез Эмитенты, пять элементов, предупреждение</title>
<desc>Макет вкладки Концентрация (mobile 390), разрез Эмитенты, валюта RUB. Центр: 3 322 000 ₽. Сбер 33,7 %; Газпром 23,5 %; ЛУКОЙЛ 19,3 %; Эмитент не указан 12,3 %; Яндекс 11,2 %; предупреждение «Данные неполные»</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="390" height="1020" rx="0" fill="#e9ecef" />
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
<g clip-path="url(#tabclip)">
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
</g>
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
<rect x="16" y="112" width="358" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="16" y="112" width="4" height="48" rx="2" fill="#2563eb" /><text x="36" y="141" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
<rect x="16" y="172" width="358" height="816" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="28" y="188" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<text x="72.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
<text x="154.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
<text x="235.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
<rect x="276.5" y="192" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="317.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
<rect x="28" y="244" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 274.00 A110 110 0 0 1 288.92 441.27 L261.60 424.61 A78 78 0 0 0 195.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="303.1" y="327.3" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
<path d="M288.92 441.27 A110 110 0 0 1 146.95 482.95 L160.93 454.17 A78 78 0 0 0 261.60 424.61 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="229.9" y="507.0" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
<path d="M146.95 482.95 A110 110 0 0 1 85.46 373.92 L117.33 376.85 A78 78 0 0 0 160.93 454.17 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="87.0" y="448.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="url(#hatch)"/>
<text x="85.0" y="330.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
<path d="M123.83 300.12 A110 110 0 0 1 195.00 274.00 L195.00 306.00 A78 78 0 0 0 144.54 324.52 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="152.3" y="271.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
<text x="195.0" y="392" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 322 000 ₽</text></g>
<text x="195.0" y="532" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
<circle cx="38" cy="578" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="54" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбер</text>
<text x="358" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
<text x="358" y="598" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">33,7 %</text>
<rect x="54" y="610" width="304" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="610" width="102.4924744130042" height="6" rx="3" fill="#2563eb" />
<rect x="54" y="632" width="304" height="1" rx="0" fill="#f1f3f5" />
<circle cx="38" cy="662" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="54" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. Газпром</text>
<text x="358" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
<text x="358" y="682" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">23,5 %</text>
<rect x="54" y="694" width="304" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="694" width="71.37868753762794" height="6" rx="3" fill="#2563eb" />
<rect x="54" y="716" width="304" height="1" rx="0" fill="#f1f3f5" />
<circle cx="38" cy="746" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="54" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. ЛУКОЙЛ</text>
<text x="358" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
<text x="358" y="766" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">19,3 %</text>
<rect x="54" y="778" width="304" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="778" width="58.56712823600241" height="6" rx="3" fill="#2563eb" />
<rect x="54" y="800" width="304" height="1" rx="0" fill="#f1f3f5" />
<circle cx="38" cy="830" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="38" cy="830" r="6" fill="url(#hatch)"/>
<text x="54" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Эмитент не указан</text>
<text x="358" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
<text x="358" y="850" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">12,3 %</text>
<rect x="54" y="862" width="304" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="#2563eb" />
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="url(#hatch)" />
<rect x="54" y="884" width="304" height="1" rx="0" fill="#f1f3f5" />
<circle cx="38" cy="914" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="54" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Яндекс</text>
<text x="358" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
<text x="358" y="934" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,2 %</text>
<rect x="54" y="946" width="304" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="946" width="34.0421432871764" height="6" rx="3" fill="#2563eb" />
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

@@ -0,0 +1,101 @@
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1144" viewBox="0 0 390 1144" role="img">
<title>Концентрация — mobile — разрез Акции</title>
<desc>Макет вкладки Концентрация (mobile 390), разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
<defs>
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
</defs>
<rect x="0" y="0" width="390" height="1144" rx="0" fill="#e9ecef" />
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
<g clip-path="url(#tabclip)">
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
</g>
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
<rect x="16" y="116" width="358" height="996" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
<rect x="32.0" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="28" y="240" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 270.00 A110 110 0 0 1 303.75 396.53 L272.11 391.72 A78 78 0 0 0 195.00 302.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="289.0" y="303.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
<path d="M303.75 396.53 A110 110 0 0 1 197.14 489.98 L196.52 457.99 A78 78 0 0 0 272.11 391.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="276.7" y="477.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
<path d="M197.14 489.98 A110 110 0 0 1 100.93 437.02 L128.30 420.43 A78 78 0 0 0 196.52 457.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="135.2" y="492.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
<path d="M100.93 437.02 A110 110 0 0 1 87.15 358.36 L118.52 364.66 A78 78 0 0 0 128.30 420.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="72.9" y="405.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
<path d="M87.15 358.36 A110 110 0 0 1 112.70 307.02 L136.64 328.25 A78 78 0 0 0 118.52 364.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
<text x="84.0" y="328.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
<path d="M112.70 307.02 A110 110 0 0 1 195.00 270.00 L195.00 302.00 A78 78 0 0 0 136.64 328.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
<text x="195.0" y="374" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
<text x="195.0" y="402" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
<text x="195.0" y="528" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
<rect x="32" y="560" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="52.0" y="585.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
<circle cx="70" cy="598" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="84" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбербанк</text>
<text x="84" y="594" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
<text x="358" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
<text x="358" y="594" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
<rect x="84" y="606" width="274" height="6" rx="3" fill="#f1f3f5" />
<rect x="84" y="606" width="75.07662024506668" height="6" rx="3" fill="#2563eb" />
<rect x="84" y="628" width="274" height="1" rx="0" fill="#f1f3f5" />
<rect x="32" y="644" width="40" height="40" rx="8" fill="#fff4e6" /><text x="52.0" y="669.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
<circle cx="70" cy="682" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="84" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. ЛУКОЙЛ</text>
<text x="84" y="678" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
<text x="358" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
<text x="358" y="678" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
<rect x="84" y="690" width="274" height="6" rx="3" fill="#f1f3f5" />
<rect x="84" y="690" width="61.07452657573223" height="6" rx="3" fill="#2563eb" />
<rect x="84" y="712" width="274" height="1" rx="0" fill="#f1f3f5" />
<rect x="32" y="728" width="40" height="40" rx="8" fill="#ebfbee" /><text x="52.0" y="753.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
<circle cx="70" cy="766" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="84" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. Яндекс</text>
<text x="84" y="762" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
<text x="358" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
<text x="358" y="762" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
<rect x="84" y="774" width="274" height="6" rx="3" fill="#f1f3f5" />
<rect x="84" y="774" width="45.58678662020033" height="6" rx="3" fill="#2563eb" />
<rect x="84" y="796" width="274" height="1" rx="0" fill="#f1f3f5" />
<rect x="32" y="812" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="52.0" y="837.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
<circle cx="70" cy="850" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="84" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Газпром</text>
<text x="84" y="846" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
<text x="358" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
<text x="358" y="846" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
<rect x="84" y="858" width="274" height="6" rx="3" fill="#f1f3f5" />
<rect x="84" y="858" width="32.39738559752339" height="6" rx="3" fill="#2563eb" />
<rect x="84" y="880" width="274" height="1" rx="0" fill="#f1f3f5" />
<rect x="32" y="896" width="40" height="40" rx="8" fill="#fff3bf" /><text x="52.0" y="921.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
<circle cx="70" cy="934" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
<text x="84" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Норникель</text>
<text x="84" y="930" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
<text x="358" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
<text x="358" y="930" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
<rect x="84" y="942" width="274" height="6" rx="3" fill="#f1f3f5" />
<rect x="84" y="942" width="23.00177604001235" height="6" rx="3" fill="#2563eb" />
<rect x="84" y="964" width="274" height="1" rx="0" fill="#f1f3f5" />
<circle cx="38" cy="994" r="6" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
<text x="54" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
<text x="54" y="1014" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
<text x="336" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
<text x="336" y="1014" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
<path d="M345 988 l5 5 l5 -5" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="54" y="1026" width="282" height="6" rx="3" fill="#f1f3f5" />
<rect x="54" y="1026" width="37.93919411625233" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
<text x="32" y="1082" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+12 -1
View File
@@ -12,7 +12,15 @@ context: |
`wealth-ledger-sdd` keep their original proposal → specs → design → tasks → apply
lifecycle and existing approvals until completion. Never infer approval from
the existence of an artifact. Schema selection comes from each change's
`.openspec.yaml`, not from the current default.
`.openspec.yaml`, not from the current default. The Change Owner is the Product
Owner for product behavior and the Engineering Owner for technical/process changes.
A session handoff never stores requirements, design or tasks and never proves an
approval; a new session without an explicitly confirmed approval does not start
downstream work. Versioned planning, spec, task and design artifacts live only in
`specs/openspec`, never in framework-specific folders elsewhere.
Canonical specs are grouped by domain as `specs/<domain>/<capability>/spec.md`;
a delta uses the same path. Process rules belong to workspace conventions, this
config and schemas, not to canonical specs.
Repository responsibilities and sources of truth:
- `main` (`./`): workspace instructions, shared configuration, development Compose
@@ -67,3 +75,6 @@ operations:
- Keep the change's selected schema and explicit approvals; do not impose v2-only artifacts on legacy changes.
- Complete implementation, developer checks, independent verification and canonical documentation before archive. For v2 changes, also check applicable QA Analysis, docs-impact and target contract compatibility with backend-generated OpenAPI.
- Native OpenSpec sync/archive updates canonical behavior specs, not implementation documentation.
- Independent verification is read-only (no edits to code, specs or tasks) and returns PASS, FAIL or SPEC_ISSUE routed to the earliest affected artifact. For UI changes it compares implemented screens, states, interactions and responsive behavior with the approved visual package.
- Archive only after the latest PASS with no Critical or Important findings and no relevant input changes since; archive routes blockers instead of fixing them.
- Completion evidence is written after the work and records, per affected repo, the commit, exact commands with observed results, N/A reasons and the review outcome. Never create empty commits to mark an unaffected repo.
@@ -0,0 +1,794 @@
name: wealth-ledger-sdd-lab
version: 1
description: Экспериментальный Engineering Lab workflow — proposal + Change Profile → specs → выбранные analysis-артефакты → design ∥ qa-analysis → tasks → apply, 4 decision gates
# Контракт OpenSpec CLI: артефакт завершён, когда существует путь `generates`; `requires` — жёсткое ребро;
# условных артефактов в CLI нет. Поэтому условные артефакты ниже не требуются ни обязательными
# артефактами, ни apply. Выбор, порядок, gates и готовность — ручной Readiness Checklist в
# instruction `process-record`. Исполняемых проверок у schema нет: gates рекомендательные, их
# соблюдение обеспечивают основной агент и independent reviewer, а не CLI.
# Раскладка change: в корне — proposal, process-record, specs/, design, tasks;
# вся аналитика — в `analysis/`.
artifacts:
- id: proposal
generates: proposal.md
description: Намерение Change Owner — зачем, результат, scope, capabilities, evidence, открытые решения
template: proposal.md
instruction: |
Владелец: Change Owner; черновик — product-analyst. Gate: G1 Product Decision.
Назначение:
Proposal фиксирует, зачем нужно изменение, какой результат ожидается, где проходит
scope и какие решения ещё не приняты. Это не requirements, не system analysis и
не design.
Старт lab lifecycle (до первой записи):
- Change создаётся явно: `openspec new change <name> --schema wealth-ledger-sdd-lab
--store wealth-ledger-specs`. Schema не становится default и не применяется к
существующим changes.
- Small не идёт в OpenSpec: fix восстанавливает поведение, уже описанное canonical
spec (spec не меняется или уточняется без изменения смысла), — правка в том же
Issue. Fix, меняющий canonical behavior, — минимум `delivery` `standard`.
Lab schema принимает только Standard и Complex.
- Если есть research (`analysis/research.md`, Issue, внешний отчёт), прочитай его
выводы; Proposal ссылается на него в **Evidence**, а не копирует. Research может
закончиться решением не делать change — тогда change не создаётся.
- Общий протокол (Change Profile, gates, Readiness Checklist, делегирование) —
instruction `process-record`; он действует на каждой стадии. Прочитай его до
первой записи: `openspec instructions process-record`.
Прочитай сначала:
- Релевантные canonical specs из `specs/openspec/specs/` и AS-IS страницы `docs/`
(`overview.md`, `architecture/`, `domains/`, `integrations/`, `adr/`, `design/`).
- Код изучай не глубже, чем нужно для scope и impact.
Правила:
- Один Proposal = одно связное намерение; независимо поставляемое разделяй.
- Capabilities перечисляют каждую новую или изменяемую behavior spec точным path.
Если observable behavior не меняется — `.openspec.yaml: skip_specs: true`.
- Ломающее observable behavior помечай **BREAKING**.
- Evidence разделяет факты (с источником) и гипотезы.
- Open Decisions: вопрос, кто решает, что блокирует. Вопрос, меняющий scope или
capabilities, решается до G1.
Не включай: файлы/классы/функции/таблицы/ORM-сущности; библиотеки/фреймворки/
транспорт; контракты endpoint/payload/error-code; диаграммы; тест-планы; tasks/
план PR; пиксельные детали UI.
Обратная связь: пробел в research → research; остальное возвращается сюда.
Проверка:
- `openspec validate <change> --store wealth-ledger-specs` (Why/What Changes);
структурный успех не является approval.
- Затем создай `process-record.md` (Change Profile) и предъяви на G1 оба файла.
requires: []
- id: research
generates: analysis/research.md
description: Необязательное продуктовое исследование — входной контекст, не источник требований
template: research.md
instruction: |
Владелец: product-researcher. Условный, пишется до proposal: research — вход
Proposal, поэтому создаётся раньше `proposal.md`, а не по Change Profile (Profile
появляется позже и лишь фиксирует факт: `yes` — `analysis/research.md` есть в
change, `no` — ссылка на внешний источник или причина). Не требуется ни одним
артефактом и не блокирует apply. В gates не входит: это контекст, не утверждаемый
артефакт.
Trigger: новый продуктовый функционал, незнакомая предметная область, конкуренты и
альтернативы, проверка гипотезы, высокая продуктовая неопределённость.
Исследование уже есть вне change (Issue, `audit-results/`, отчёт) — не копируй,
Proposal ссылается на источник.
Research понадобился после G1 (новая неопределённость) — это Open Decision в
Proposal: research дописывается, Proposal обновляется, G1 инвалидируется.
Правила:
- Каждое утверждение помечено: [F] факт с источником, [I] интерпретация, [H] гипотеза.
- Не выдумывай источники, цифры и пользователей; нет данных — так и напиши.
- Рекомендация допускает «не делать» и «сузить scope».
Не включай: окончательные requirements, обязательные архитектурные решения,
контракты, tasks.
Обратная связь: выводы потребляет Proposal (Evidence, Scope, Open Decisions).
requires: []
- id: process-record
generates: process-record.md
description: Change Profile, решения gates, снятая работа и lab-журнал change
template: process-record.md
instruction: |
Владелец: основной агент (оркестратор). Change Profile утверждает Change Owner на
G1. Это журнал процесса, а не planning authority: requirements, контрактов и
design здесь нет.
## Общий протокол lab schema (действует на каждой стадии)
Режимы и классы:
- **Mode** `lab` — целенаправленная практика: G2 обязателен, independent review на
каждом gate, Lab Journal после каждой стадии, Retrospective перед archive;
условные артефакты можно выбирать ради названной практики.
- **Mode** `delivery` — минимально достаточный процесс: артефакты только по
risk-trigger, qa-analysis компактный, Lab Journal необязателен.
- **Class** `standard` | `complex`. Complex: межсистемно, деньги/синхронизация/
данные, необратимые миграции или высокая неопределённость. Mode и Class
независимы.
Шаблоны: HTML-комментарии в шаблонах — подсказки для автора. В готовом артефакте их
нет; удаляй до gate этого артефакта.
Качество требований: единый профиль для автора и reviewer —
`openspec/schemas/wealth-ledger-sdd-lab/spec-writing.md`. До утверждения Specs
автор проходит нормоконтроль и смысловую проверку набора из раздела Review;
назначенный по режиму reviewer перепроверяет их на существующем G2/G3.
Результат — в Validation и Findings Gate Log. Вопрос, меняющий норму или ожидаемый
результат, блокирует gate; предпочтение стиля само по себе не блокирует.
Change Profile (обязательная таблица, все 10 строк):
- `yes` — только при конкретном trigger из instruction артефакта; в lab допустим
`yes` с названной практикой («lab: практика data mapping»).
- `no` — с конкретной причиной. Пустой документ с «N/A» вместо `no` запрещён.
- Profile меняется только решением Change Owner: строка текущего gate (G1–G3) со
Scope `change-profile`; все последующие gates при этом инвалидируются.
- Выбранный условный вход пишется раньше потребителя: research — до proposal;
ux-design, system-analysis, data-mapping, failure-model, nfr, observability,
security-analysis — до design, qa-analysis и tasks; test-cases, docs-impact —
до tasks.
Граница контроля:
- CLI проверяет только существование файлов и структуру delta specs. Profile,
порядок, gates и готовность — Readiness Checklist ниже: его проходит основной
агент, independent reviewer gate перепроверяет. Скриптов у schema нет.
- Checklist — не approval и не замена review: он ловит форму, смысл — reviewer.
- Результат checklist пишется в колонку Validation Gate Log
(`readiness: plan OK` | `readiness: apply OK` | список нарушений).
Следующий артефакт:
- `openspec status` показывает невыбранные условные артефакты как `ready`, а
`isPlanningComplete` остаётся `false`, пока они не созданы. Это ограничение CLI:
не создавай артефакт только потому, что он `ready`.
- Следующий артефакт — первый ещё не созданный и разрешённый последним approved
gate в порядке: proposal → process-record → specs → выбранные ux-design /
system-analysis и модули / security-analysis → design ∥ qa-analysis → выбранные
test-cases / docs-impact → tasks.
Decision Gates (Gate Log, только дописывание):
| Gate | Scope | Когда |
| --- | --- | --- |
| G1 Product Decision | proposal, change-profile | всегда |
| G2 Requirements Baseline | specs (+ system-analysis и модули для complex) | lab или complex |
| G3 Solution Approval | остальное planning, включая tasks | всегда |
| G4 Delivery Acceptance | implementation, review, QA, parity, docs | перед archive |
- Без G2 (delivery standard) specs входят в Scope G3.
- Complex: выбранные system-analysis, data-mapping, failure-model, nfr,
observability входят в G2, чтобы системные обязательства были согласованы до
design (contract-first). Hub не обязателен: утверждается только выбранное.
- Каждый артефакт утверждается только на своём gate: proposal — G1; change-profile
— G1 (позднее изменение — G2/G3); specs — G2 (без G2 — G3); system-analysis и
модули — G2 для complex, иначе G3 (или G2, если он есть); остальное planning —
G3. Approval на чужом gate не засчитывается. Scope G1–G3 содержит только эти ids;
неизвестный id — ошибка записи. Scope G4 — implementation, review, QA, parity,
docs.
- Решение gate явно разрешает черновик следующего пакета артефактов. Approval
перечисляет каждый id в Scope и дословно цитирует слова Change Owner из текущей
сессии или комментария Issue (со ссылкой). Никогда не записывай решение, которого
владелец не давал; существование файла или строки Gate Log — не approval.
- Состояние gate = его ПОСЛЕДНЯЯ строка в Gate Log. `rejected`,
`changes requested` и `invalidated: <причина>` снимают approval этого gate и всех
последующих; новое approval — по порядку. Прежний approved выше в журнале не
действует.
- Каждый gate разделяет: Artifact Validation (CLI + Readiness Checklist),
Independent Review (findings) и Decision Approval (владелец). Validation и
Findings заполняются до запроса решения.
- Смысловое изменение утверждённого артефакта: строка `invalidated: <причина>` для
gate, покрывающего самый ранний затронутый артефакт (снимает и все последующие),
затем повторное approval по порядку. Механические правки (опечатка, формат) не
инвалидируют.
- Release Approval — вне OpenSpec (release-it, Gitea); G4 его не заменяет.
Readiness Checklist (проходи целиком; любой пункт «нет» — нарушение):
plan — после каждого артефакта:
- [ ] `**Mode:**` = lab|delivery, `**Class:**` = standard|complex, Issue указан.
- [ ] Change Profile: ровно 10 строк, Selected = yes|no, Reason непустой; `yes` в
delivery называет trigger, в lab — trigger или практику.
- [ ] Нет файлов невыбранных условных артефактов; `skip_specs: true` ⇔ нет `specs/`.
- [ ] Каждый выбранный условный вход существует раньше потребителя (см. Change
Profile).
- [ ] В артефактах, предъявляемых на gate, нет HTML-комментариев шаблона.
- [ ] Каждая ссылка `` `capability` — `Requirement` `` указывает на heading из delta
или canonical spec; после RENAMED — только на новое имя.
- [ ] Gate Log: артефакт засчитан только на своём gate; в Scope нет неизвестных ids;
Scope G1 включает `change-profile`; состояние gate — по последней строке; каждое
approved содержит дословную цитату и непустую Validation.
- [ ] Specs: нет дублей `### Requirement:` в одном файле; MODIFIED/REMOVED/RENAMED
FROM ссылаются на существующий canonical heading.
- [ ] Для предъявляемых на gate Specs пройдены оба прохода Review из
spec-writing.md: качество требований и полнота/согласованность итогового набора;
нет нерешённых вопросов о нормативном поведении.
- [ ] test-cases: ids `TC-` уникальны, у каждого case есть Verifies в формате ссылки.
- [ ] Каждый файл `analysis/contracts/` и `analysis/diagrams/` упомянут в
`analysis/*.md`; JSON/YAML-контракты синтаксически валидны.
apply — plan плюс:
- [ ] Существуют proposal, process-record, specs (или `skip_specs: true`), design,
qa-analysis, tasks и все выбранные условные артефакты.
- [ ] Текущее состояние approved: G1; G2 (lab или complex); G3. Каждый существующий
артефакт (кроме research и process-record) входит в approved Scope.
- [ ] С момента approval нет смысловых правок утверждённых артефактов без
`invalidated`.
- [ ] Каждый ADDED/MODIFIED/REMOVED Requirement имеет строку в Coverage Matrix
qa-analysis и хотя бы одну task с ним в Covers. Упоминание вне матрицы и Covers —
не покрытие. REMOVED покрывается проверкой миграции/регрессии.
- [ ] В tasks есть Independent Review; Contract Parity при наличии
`analysis/contracts/`; раздел `## Docs Impact` в tasks.md, если docs-impact `no`.
- [ ] Descoped Work: каждая строка с причиной и gate G3/G4.
archive — apply плюс:
- [ ] Все tasks `[x]` или перенесены в Descoped Work.
- [ ] Состояние G4 — approved; Validation G4 содержит verify, review, QA, parity
(если есть контракты), docs.
- [ ] Lab: Retrospective заполнена.
Independent Review (lab — на каждом gate; delivery — минимум G4):
- G1: product-analyst ищет в проблеме и scope противоречия и скрытые решения.
- G2: qa-engineer — тестируемость; independent-reviewer — оба прохода Review из
spec-writing.md для delta и итогового набора с canonical specs.
- G3: independent-reviewer — согласованность specs ↔ analysis ↔ design ↔
qa-analysis ↔ tasks; без G2 также профиль Specs; security-reviewer, если выбран
security-analysis.
- G4: independent-reviewer — код против утверждённых артефактов; qa-engineer —
системная проверка по qa-analysis.
- Reviewer не исправляет артефакт; findings идут по правилам обратной связи.
Делегирование сабагентам:
- Сабагент — самостоятельный профессионал: не знает OpenSpec, gates и других
сабагентов. Основной агент выбирает роль, пишет Task Brief, получает результат,
размещает его в артефакте, проверяет согласованность и выносит вопросы человеку.
- Task Brief: профессиональная цель («спроектируй межсистемные взаимодействия
для …», а не «создай artifact system-analysis»); точные входные файлы и разделы;
ограничения из «Не включай» артефакта, сформулированные профессионально;
ожидаемые разделы ответа; правило «неизвестное — вернуть вопросом, не выдумывать».
- Сабагент не принимает решений за другие роли; конфликт с upstream возвращается
как finding.
Lab Journal (lab): строка после каждой стадии — уникальные находки (что нашла
только эта стадия), решения, риски, проблемы передачи контекста, skills, стоимость
(вызовы, токены/время), что переносимо на крупные проекты. Не пиши ожидаемые
результаты заранее.
requires:
- proposal
- id: specs
generates: "specs/**/*.md"
description: Нормативные black-box дельты поведения, бизнес-правила и инварианты
template: spec.md
instruction: |
Владелец: product-analyst (requirements engineering). Gate: G2 (lab/complex) или G3.
Reviewers: qa-engineer (тестируемость), independent-reviewer (согласованность).
Назначение:
Delta Specs — нормативный black-box контракт: что система обязана делать для
пользователя или внешнего наблюдателя. После archive становятся canonical specs.
Предусловия:
- G1 approved; используются только capabilities из Proposal.
- Для существующей capability прочитай canonical spec целиком.
- `skip_specs: true` — spec-файлы не создаются.
- Прочитай `openspec/schemas/wealth-ledger-sdd-lab/spec-writing.md`: это единый
профиль написания и ревью, включая эталоны, а не необязательные советы.
Правила:
- Путь дельты — `specs/<capability-path>/spec.md`, capability-path точно из Proposal.
- Один Requirement = одно независимо принимаемое наблюдаемое обязательство или
инвариант. Единый атомарный результат не дроби по числу SHALL.
- Новая норма: субъект + SHALL / SHALL NOT + результат с явным условием, если оно
нужно. MUST / MUST NOT равнозначны; BCP 14 — по политике `config.yaml`.
- Общая норма, нужные определения/таблицы, затем ≥1 `#### Scenario:` для
ADDED/MODIFIED: GIVEN по необходимости, конкретные WHEN/THEN. Scenario не
создаёт новую норму. Существующие headings и сохраняемые Scenarios не теряй.
- Термины бери из затронутых specs и предоставленного глоссария; новое определение
размещай внутри владеющего Requirement, а не только в исследовании или примере.
- Бизнес-правила, инварианты (сохранение суммы, однократный учёт денег, знак и
точка округления), единицы и существенные границы — явные нормы и Scenarios.
Для ветвлений/жизненного цикла используй таблицы по профилю, без дублирования
правил прозой. Неизвестные пороги и политики не выдумывай.
- Допущение не оформляется как факт: неизвестное правило → вопрос в Open
Decisions Proposal с владельцем и зависимым требованием; зависимый gate ждёт
решения. Наблюдение в коде не подменяет согласованное намерение.
- Не дели Requirements по repositories; имена систем/транспорт/endpoints —
в system-analysis, если сама их identity не внешнее обязательство.
Delta-операции:
- `ADDED`; `MODIFIED` — ПОЛНЫЙ существующий блок со всеми scenarios, heading
точно; `REMOVED` — **Reason** и **Migration**; `RENAMED` — `FROM:`/`TO:`.
- Новая capability: `## Purpose` 1–2 предложения (≥50 символов).
- Не оставляй пустые разделы операций.
Формат ссылки (для всех downstream артефактов и Readiness Checklist):
`` `capability-path` — `Requirement heading` `` — heading точно. Пример:
`` `portfolio/holdings` — `Итог портфеля не учитывает дубли операций` ``.
Переименование heading после G2 — это RENAMED + invalidation; ссылки обновляются.
Не включай: детали реализации, поведение вне Proposal, «корректно»/«быстро»/
«удобно» без критерия, противоречия canonical specs.
Обратная связь: намерение/scope/capabilities → Proposal; системы/контракты/
mappings → system-analysis; детали взаимодействия → ux-design.
Проверка: `openspec validate <change> --strict --store wealth-ledger-specs`, затем
оба прохода Review из spec-writing.md. Успех CLI не доказывает полноту смысла;
проверяй delta вместе с получающимся canonical набором.
requires:
- process-record
- id: ux-design
generates: analysis/ux-design.md
description: Необязательный interaction design утверждённого поведения; часть Design package
template: ux-design.md
instruction: |
Владелец: ux-designer. Условный: только если Change Profile выбрал `ux-design`.
Gate: G3 вместе с design (единый Design package, отдельного UX approval нет).
Trigger: новые экраны, существенное изменение пользовательского сценария, редизайн,
сложные формы, многосостоянийный интерфейс. Иначе UI описывается в разделе
UI Implementation `design.md` (reuse или N/A с причиной).
Полномочия:
- ux-design — UX-часть единого Design package (не отдельный approval): внутри
пакета он один владеет flows, IA, состояниями, feedback, responsive и a11y.
design.md ссылается на него и фиксирует только инженерную реализацию UI.
- Observable behavior остаётся в Specs; UX не вводит новое поведение. Состояние или
ошибка, которых нет в Specs, — finding → Specs.
Правила:
- Ссылайся на Requirements, не пересказывай их.
- UI States — loading/empty/error/success/partial только там, где они реально
возможны.
- Визуальные материалы — по правилу Visual Package из instruction `design`.
- Используй существующую design system (`docs/design/`, UI kit `frontend`) прежде
нового паттерна; отклонение обоснуй.
Не включай: серверную архитектуру, контракты, дерево компонентов, state
management, data fetching.
Обратная связь: поведение → Specs; реализуемость → design; данные/ошибки
интеграции → system-analysis.
requires:
- specs
- id: system-analysis
generates: analysis/system-analysis.md
description: Необязательный hub межсистемного контекста — границы, flows, владение данными, контракты
template: system-analysis.md
instruction: |
Владелец: system-analyst. Условный: только если Change Profile выбрал
`system-analysis`. Gate: G2 для complex, иначе G3.
Trigger: новый или изменённый межсистемный контракт (HTTP API, внешняя интеграция,
event), изменение владения данными, несколько систем в одном flow, сложная
миграция/coexistence. Изменение внутри одного repo без контракта — не trigger.
Структура (hub + модули; каждый модуль — отдельный выбор Change Profile, hub для
модуля не обязателен):
- `analysis/system-analysis.md` — контекст, границы, As-Is/To-Be, impact,
ответственности, владение данными, обзор flows, семантика контрактов и ошибок на
границах, совместимость, ссылки на модули.
- `analysis/contracts/` — машиночитаемые target-контракты (OpenAPI/AsyncAPI/
JSON Schema/Proto). Не отдельный артефакт: принадлежит hub.
- `analysis/diagrams/*.mmd` — Mermaid sequence/state/data-flow, только если
помогают понять поведение. Каждый файл упомянут в hub или модуле.
- Модули: `data-mapping.md`, `failure-model.md`, `nfr.md`, `observability.md`.
- Расширения (audit, fraud, feature toggles, CMS, внешние системы) — новым
модулем только при реальном requirement; schema их не предопределяет.
Полномочия контракта (из v2):
- Новый или изменённый контракт определяется в `analysis/contracts/` до
реализации.
- Backend-generated OpenAPI остаётся исполняемым runtime-источником после
реализации и MUST быть семантически совместим с утверждённым target-контрактом
до G4.
- Не создавай второй вечный canonical OpenAPI в `specs/`; target-контракт остаётся
в архивном change. Расхождение generated ↔ target возвращает change сюда.
- Для существующего HTTP API прочитай текущий backend-generated OpenAPI
(`frontend/src/shared/api/spec/openapi.json` — опубликованный snapshot).
- Каждый контракт помечает совместимость: additive | breaking (+ migration).
Правила:
- Не вводи observable behavior молча: пробел → Specs и повторный G2/G3.
- Hub не копирует модули; модуль не копирует hub.
- Нет внутренних классов/модулей/таблиц БД/developer tests.
Обратная связь: поведение → Specs; внутренняя реализация → design; проверка →
qa-analysis.
requires:
- specs
- id: data-mapping
generates: analysis/data-mapping.md
description: Необязательный поле-в-поле mapping, преобразования и инварианты сохранения между системами
template: data-mapping.md
instruction: |
Владелец: system-analyst; reviewer — database-specialist при изменении хранения.
Условный: только если выбран `data-mapping`; hub не обязателен.
Trigger: преобразование данных между системами или моделями (внешний API →
domain, domain → API, агрегаты), денежные суммы, валюты, знаки, классификация
записей по группам.
Правила:
- Каждое target-поле: источник, преобразование, unit/валюта/scale, nullability,
default, валидация, поведение при ошибке.
- Инварианты: правила сохранения (сумма частей = итог, каждая денежная единица
учтена ровно в одном агрегате), знаки, точка округления — со ссылкой на
защищаемый Requirement. Нет Requirement — finding → Specs.
- Двойной учёт: каждый тип исходной записи, который может попасть в два агрегата,
и правило, относящее его ровно к одному.
- Большой mapping — машиночитаемый файл в `analysis/contracts/` со ссылкой отсюда.
Не включай: ORM/SQL/код mappers, retry/idempotency (failure-model).
Обратная связь: инвариант без Requirement → Specs; владение данными → hub;
реализация → design.
requires:
- specs
- id: failure-model
generates: analysis/failure-model.md
description: Необязательная семантика отказов на границах — таймауты, повторы, идемпотентность, частичный отказ, восстановление
template: failure-model.md
instruction: |
Владелец: system-analyst; reviewer — reliability-specialist.
Условный: только если выбран `failure-model`; hub не обязателен.
Trigger: внешняя интеграция, фоновая синхронизация, повторяемые операции,
многошаговая запись, возможны дубли/out-of-order/частичный отказ.
Правила:
- Каждый сценарий отказа: граница, отказ, обнаружение, требуемое поведение системы,
observable результат, Requirement.
- Идемпотентность: ключ, scope, срок хранения, результат повторного запроса/события.
- Частичный отказ: что остаётся согласованным, компенсация или восстановление.
- Обязательства формулируются на границе систем; механизм (lock, очередь,
транзакция) — design.
Не включай: численные SLO (nfr), выбор библиотек, код.
Обратная связь: новое видимое пользователю поведение при отказе → Specs;
механизм → design; проверка → qa-analysis.
requires:
- specs
- id: nfr
generates: analysis/nfr.md
description: Необязательные измеримые нефункциональные требования с порогами и способом измерения
template: nfr.md
instruction: |
Владелец: system-analyst; reviewer — reliability-specialist.
Условный: только если выбран `nfr`; hub не обязателен.
Trigger: значимые ограничения performance/availability/capacity/retention/security,
которые меняют design или проверку.
Правила:
- Каждый NFR: id, порог, способ измерения, обоснование/источник.
- Для численного порога определи единицы, условия/нагрузку, точки начала и конца
измерения, окно или агрегат по необходимости. Контекст измерения нужен,
чтобы разные авторы и QA проверяли одно обязательство.
- Нет порога — раздел Constraints Without Thresholds с владельцем решения; числа
не выдумывай. Если от порога зависит design или приёмка, вопрос блокирует
соответствующий gate до решения.
- Security-NFR живут здесь; security-analysis ссылается на них по id.
Не включай: механизмы достижения (design), семантику отказов (failure-model),
события наблюдаемости (observability).
requires:
- specs
- id: observability
generates: analysis/observability.md
description: Необязательные обязательства наблюдаемости — события, сигналы, корреляция, SLI и алерты
template: observability.md
instruction: |
Владелец: system-analyst; reviewer — reliability-specialist.
Условный: только если выбран `observability`; hub не обязателен.
Trigger: фоновые/асинхронные процессы, финансовые расчёты или синхронизация, где
оператор должен различать успех, частичный успех и отказ; новые SLI.
Правила:
- Семантические события с trigger и обязательными атрибутами (например,
`code=1 → NO_MONEY`); корреляция через границы систем.
- Диагностические сценарии: «когда X, оператор определяет Y по Z».
- SLI/алерты — только с определением и целевым значением; цель из nfr — ссылкой.
Не включай: SDK/exporter/logger/инфраструктуру (design), секреты и персональные
данные в атрибутах.
requires:
- specs
- id: security-analysis
generates: analysis/security-analysis.md
description: Необязательная threat model этого change — mitigations и остаточные риски
template: security-analysis.md
instruction: |
Владелец: security-reviewer. Условный: только если выбран `security-analysis`.
Gate: G3; требования к проверке исполняются на G4.
Trigger: authentication/authorization, токены и секреты, новая trust boundary,
чувствительные/финансовые персональные данные, внешняя интеграция, изменение
модели доступа. Без этих признаков не создавай.
Входы: Specs; system-analysis и nfr, если выбраны; существующие ADR и
`docs/architecture/cross-cutting/`.
Правила:
- Threat model только этого change (STRIDE — способ поиска, не чек-лист).
- Каждая mitigation назначена владельцу: observable требование → Specs,
межсистемное обязательство → system-analysis, механизм → design.
- Security-NFR не дублируй: ссылайся на id из `nfr.md`.
- Остаточные риски явно принимает Change Owner на G3.
Не включай: универсальный security checklist, эксплуатацию внешних систем, секреты.
requires:
- specs
- id: design
generates: design.md
description: Инженерный design — как affected repositories реализуют утверждённое поведение и системные обязательства
template: design.md
instruction: |
Владелец: solution-architect; reviewers — affected frontend/backend engineers,
database-specialist при миграциях. Gate: G3.
Назначение:
Как affected repositories реализуют свою сторону утверждённых Specs и выбранных
analysis-артефактов. Это не повторный system analysis.
Предусловия:
- Все выбранные условные входы из `analysis/` уже существуют (Readiness Checklist
`plan` без нарушений). Complex: выбранные system-analysis и модули утверждены
на G2.
- Протокол (gates, checklist, обратная связь) — instruction `process-record`;
Change Profile в `process-record.md` определяет, какие входы читать.
- Upstream контракты/sequences/mappings — входные ограничения.
Глубина:
- Delivery standard: Inputs, Implementation Impact Map, Decisions и
Risks / Trade-offs обязательны; остальные разделы — только при наличии
содержания (Solution Assessment и Rollback — по их trigger ниже); пустые удаляй,
не пиши N/A.
- Lab или complex: все разделы шаблона; N/A — с причиной в одну строку.
Правила:
- Не добавляй observable behavior и не меняй системные контракты. Нереализуемый
контракт → system-analysis, а не private-контракт здесь.
- Каждое существенное решение ссылается на driver: Requirement, id NFR, failure,
threat или ограничение. Новая технология/компонент без driver запрещены.
- UI: при ux-design — только инженерная реализация (композиция, state, data
fetching, как получается каждое UI-состояние); без ux-design — раздел
UI Implementation: new/changed/reuse/N/A с причиной и нужные экраны/состояния.
Неизменяемый UI и N/A макетов не требуют.
- Visual Package (единое правило для ux-design и UI Implementation): версия
пакета; для reuse — неизменяемый locator (store ID + path от OpenSpec root),
применимая часть и отличия; локальные PNG/SVG в `visuals/` с относительным путём,
назначением и SHA-256; SVG самодостаточен (без scripts, handlers, внешних
шрифтов/ресурсов); изменяемая внешняя ссылка не является утверждённой версией.
ux-design и design — один Design package с одним approval на G3.
- Solution Assessment обязателен для нового auth, security, storage, внешней
интеграции, observability, кеширования, конкурентности, общего tooling или иного
дорогого необратимого решения; иначе N/A + причина.
- Rollback обязателен для security, разрушающей миграции, публичного контракта и
труднообратимых решений.
- Developer verification определяют implementation owners; QA-матрицу не копируй.
- ADR Candidates: только долгоживущие архитектурные решения с альтернативами.
Не включай: утверждённые sequences/контракты/mappings (ссылки, не копии); UX flows/
состояния при наличии ux-design; QA-покрытие; план docs; tasks/план PR; длинный
production-код.
Обратная связь: намерение → Proposal; поведение → Specs; взаимодействие →
ux-design; контракты/mappings/отказы/наблюдаемость → system-analysis и модули;
угрозы → security-analysis.
requires:
- specs
- id: qa-analysis
generates: analysis/qa-analysis.md
description: Risk-based стратегия системной проверки, независимая от developer tests
template: qa-analysis.md
instruction: |
Владелец: qa-engineer. Gate: G3. Может выполняться параллельно с design.
Назначение:
Что проверить на уровне системы, чтобы доказать соответствие Specs и выбранным
analysis-модулям, и с какой глубиной — по риску.
Прочитай `openspec/schemas/wealth-ledger-sdd-lab/spec-writing.md`: ожидаемый исход
берётся из нормы, а не из догадки QA или фактического поведения реализации.
Глубина (условные разделы внутри обязательного артефакта):
- Всегда: Risk Assessment, Coverage Matrix (строка на каждый ADDED/MODIFIED/REMOVED
Requirement с указанием Scenarios/ветвей правила и способа проверки), Gaps /
Blockers. При `skip_specs: true` матрица фиксирует существующее поведение или
согласованное ограничение для регрессии и его источник, без выдуманных дельт.
- Delivery standard: остальные разделы — только при наличии содержания; пустые
удаляй, не пиши N/A.
- Lab или complex: все разделы; Cross-system Verification обязателен при выбранных
system-analysis, failure-model или data-mapping (каждый failure id и инвариант
имеет проверку или обоснованный risk-based отказ).
Правила:
- QA проверяет поведение системы, не устройство кода; не предписывай unit/component
tests, mocks, процент покрытия.
- Не создавай поведение: неопределённый ожидаемый результат → пробел в Specs;
неопределённый flow/ошибка/контракт → пробел в system-analysis.
- Ссылайся на Scenarios, не переписывай их. Отдельные test cases — только через
артефакт `test-cases`.
- По риску проверь существенные границы, строки таблиц, переходы, повторы,
конкуренцию и отсутствие запрещённых побочных эффектов. Пропуск проверки —
явное `not verified: <риск-причина>` для названного случая в матрице, не молчание.
- Планы проверок отделены от результатов: результаты фиксируются на G4.
- Automation: black-box кандидаты API/E2E; QA repo пока нет — `deferred until QA
repo exists`.
Обратная связь: поведение → Specs; контракты/отказы → system-analysis;
testability hooks → design (как потребность, без проектирования).
requires:
- specs
- id: test-cases
generates: "analysis/test-cases/*.md"
description: Необязательный явный test design для рискованных сценариев, которым нужны конкретные данные, шаги или oracle
template: test-cases.md
instruction: |
Владелец: qa-engineer. Условный: только если выбран `test-cases`. Gate: G3.
Trigger: финансовые расчёты, сложные бизнес-сценарии, значимые интеграции, много
ошибок и граничных случаев, критичное пользовательское поведение — когда проверке
нужны конкретные данные, шаги или oracle, которых нет в Scenario.
Правила:
- Один файл на область: `analysis/test-cases/<area>.md`; id `TC-<AREA>-<NNN>`
уникален.
- Каждый case: Verifies (формат ссылки), риск/приоритет, предусловия, тестовые
данные, шаги, ожидаемый результат, кандидат в автоматизацию.
- Case добавляет информацию сверх Scenario (числа, комбинации, порядок событий,
расчётный oracle). Не добавляет — не пиши case; Coverage Matrix ссылается на
Scenario.
- Не копируй acceptance criteria дословно; ожидаемый результат выводится из
Requirement, а не придумывается. Неопределённость → пробел в Specs.
- Coverage Matrix qa-analysis ссылается на TC ids.
requires:
- qa-analysis
- id: docs-impact
generates: analysis/docs-impact.md
description: Необязательный план переноса знаний change в canonical AS-IS документацию
template: docs-impact.md
instruction: |
Владелец: documentation-engineer. Условный: только если выбран `docs-impact`.
Gate: G3.
Trigger: несколько docs targets, новый ADR, перенос диаграмм, изменение страниц
architecture/integration, lab-практика Documentation Impact Analysis. Иначе —
раздел `## Docs Impact` в tasks.md (targets или конкретная причина без изменений).
Модель документации: `docs/` — AS-IS (`overview.md`, `glossary.md`,
`architecture/`, `architecture/cross-cutting/`, `domains/`, `integrations/`,
`adr/`, `design/`). Принятый ADR не переписывается.
Правила:
- Переносится только знание, полезное после завершения change; без дневника
миграции.
- Не копируй машиночитаемый контракт в docs вторым источником истины.
- Каждая запись: Action, точный Target (существующий path проверен), Category,
итоговое AS-IS, Source, Verification.
- Перенос диаграмм по умолчанию — нет.
- docs-impact не редактирует `docs/`; tasks создают пункты `[docs]`.
requires:
- design
- id: tasks
generates: tasks.md
description: Упорядоченный по зависимостям delivery checklist по repositories с проверками, review и parity
template: tasks.md
instruction: |
Владелец: основной агент (координация delivery); reviewers — affected engineers.
Gate: G3.
Предусловия:
- design и qa-analysis существуют; все выбранные условные артефакты существуют
(Readiness Checklist `plan` без нарушений).
- Для complex/lab: G2 approved до записи tasks.
- Протокол (gates, checklist, Descoped Work) — instruction `process-record`.
Формат task:
`- [ ] X.Y [main|frontend|backend|docs|specs] <Kind>: <действие + результат>`
с полями **Covers**, **Source**, **Verification**, **Depends on**.
Kinds: Implementation, Migration, Automated Testing, Contract Parity, Docs,
Independent Review, QA Verification, Verification.
Обязательные tasks (только релевантные; отмеченные * проверяет Readiness Checklist):
- Implementation/Automated Testing по каждому repository согласно design.
- Migration: при изменении хранения — backup/rollback и проверка на копии данных.
- * Contract Parity: при наличии `analysis/contracts/` — сравнение backend-generated
OpenAPI/runtime с target-контрактом.
- Docs: из docs-impact или раздела `## Docs Impact`.
- * Independent Review: read-only review реализации против утверждённых артефактов.
- QA Verification: системные проверки по qa-analysis (и test-cases).
- Security: проверки из Validation Requirements security-analysis, если выбран.
- Native sync/archive — не task; canonical specs задачами не правятся.
Правила:
- Одна task = один repository + один независимо проверяемый результат.
- * Каждый ADDED/MODIFIED/REMOVED Requirement есть в Covers хотя бы одной task
(REMOVED — task миграции/регрессии).
- Covers в формате ссылки; неизвестный heading — нарушение checklist.
- У каждой task все четыре поля; Verification — наблюдаемая команда или результат,
а не «проверить, что работает».
- `[qa]` запрещён, пока QA repo не существует.
- Не создавай церемониальные tasks; нерелевантный Kind не нужен.
- Снятие после G3: удали task из tasks.md и запиши в `Descoped Work`
process-record с причиной и gate решения владельца.
Не включай: новое поведение/контракт/design/QA/docs-решение (→ upstream артефакт),
commits/branches, будущие результаты.
Обратная связь: смысловое изменение → самый ранний затронутый артефакт с
инвалидацией его gate и последующих.
requires:
- design
- qa-analysis
apply:
requires:
- tasks
tracks: tasks.md
instruction: |
Apply реализует утверждённый план и не переоткрывает upstream-решения без цикла
обратной связи.
Предусловия (CLI проверяет только наличие tasks.md, остальное — вручную):
- Readiness Checklist `apply` (instruction `process-record`) пройден целиком и
записан в Validation последней строки G3; любое нарушение блокирует apply.
Главное: все обязательные и выбранные артефакты существуют; текущее состояние
G1, G2 (lab/complex), G3 — approved по последней строке.
- Перед зависимой UI-работой проверь наличие и SHA-256 материалов из таблицы
Materials (ux-design или UI Implementation в design).
- Store `wealth-ledger-specs`; протокол — instruction `process-record`.
Загрузка контекста: `contextFiles` из apply instructions, затем `AGENTS.md` только
affected repositories, затем нужные upstream-разделы для каждой task.
Выполнение:
- Незакрытые tasks — в порядке зависимостей; один repository на task; делегируй по
роли (frontend-engineer, backend-engineer, database-specialist) с Task Brief.
- Checkbox отмечается только после наблюдаемого успеха Verification.
- Contract Parity: backend-generated OpenAPI семантически совместим с
target-контрактом.
- Independent Review выполняет independent-reviewer (read-only) по полному diff
против утверждённых артефактов; Major findings исправляются или возвращаются в gate.
- QA Verification выполняет qa-engineer по qa-analysis; результаты — в Gate Log G4.
- Lab: строка Lab Journal после implementation, review и QA.
- Снятие task — только решением владельца с записью в Descoped Work.
Перед archive (G4 Delivery Acceptance):
- Все tasks `[x]`; снятые — только через `Descoped Work`.
- Validation G4: developer checks, parity, вердикт review, результаты QA, docs.
- Lab: Retrospective заполнена.
- Readiness Checklist `archive` пройден и записан в Validation G4, затем
`openspec archive <change> --store wealth-ledger-specs`. Никогда
`--no-validate`. `--yes` подавляет и подтверждение обновления specs, и
предупреждение о незакрытых tasks, поэтому допустим только сразу после
пройденного checklist `archive` (в non-TTY агентской среде без него archive не
выполняется).
Семантика sync/archive: native archive обновляет canonical specs; `analysis/`,
контракты и process-record остаются в архивном change; `docs/` меняются только
tasks `[docs]`; значимые решения — неизменяемый ADR.
Обратная связь:
- намерение/scope → Proposal; поведение/инварианты → Specs; взаимодействие →
ux-design; системы/контракты/mappings/отказы/NFR/наблюдаемость → system-analysis
и модули; угрозы → security-analysis; внутренняя реализация → design; проверка →
qa-analysis/test-cases; docs → docs-impact; декомпозиция → tasks.
- При смысловом конфликте останови downstream-работу, приложи evidence,
инвалидируй gates начиная с самого раннего затронутого артефакта.
@@ -0,0 +1,242 @@
# Spec Writing Profile
Профиль написания и ревью требований `wealth-ledger-sdd-lab`. Цель — одинаковое
понимание поведения разными авторами, разработчиками и QA, а не дословно одинаковые
документы. Это адаптация инженерных практик, не заявление о соответствии стандартам.
## Scope
Автор и reviewer читают профиль перед подготовкой или согласованием Specs. Правила
применяются к новым и изменяемым требованиям; весь canonical store ради стиля не
переписывается. Пути, delta-операции и gates задаёт `schema.yaml`.
Specs определяют наблюдаемое поведение. Обоснования и открытые решения — в Proposal,
межсистемные детали — в `analysis/`, механизмы — в design, способы доказательства —
в QA. Ни один downstream-артефакт не вводит новое поведение молча.
## Writing Rules
1. **Основание.** Каждое обязательство следует из согласованного намерения или
существующего контракта. Наблюдение в коде, гипотеза и пример — не решение
владельца. Неизвестное запиши в Open Decisions: вопрос, кто решает, зависимое
требование и что блокируется. Несогласованные числа и варианты не становятся нормой.
Для спорного или выведенного при анализе Requirement перед первым Scenario добавь
`**Источник (ненормативно):**` — ссылку на конкретное решение Change Owner в Issue
или пункт согласованной редакции контракта. Ссылка должна оставаться пригодной из
canonical spec после archive; относительной ссылки на активный Proposal недостаточно.
Источник объясняет происхождение, но не заменяет полную норму или approval;
обоснование остаётся в Proposal.
2. **Термины.** Используй названия из затронутых canonical specs и предоставленного
проектного глоссария. Один термин — одно значение в названном контексте. «Счёт»,
«брокерский счёт» и «учётная запись» не взаимозаменяемы. Новый или спорный термин
определи в теле владеющего им Requirement; остальные требования ссылаются на него.
Определение должно различать случаи, а не заменять слово синонимом. Общие термины
передаются в глоссарий через Docs Impact; новый файл словаря на каждый change не нужен.
3. **Атомарность.** Один Requirement — одно независимо принимаемое обязательство.
Если одну часть можно принять, а другую отклонить — раздели. Связанные условия,
сохранность данных при отказе и единый атомарный результат остаются вместе.
Число союзов или SHALL не определяет атомарность.
4. **Явная норма.** Назови субъект, условие применимости и наблюдаемый результат.
Для нового обязательства предпочитай SHALL / SHALL NOT; существующие MUST /
MUST NOT равнозначны и сохраняются. SHOULD / MAY не заменяют неопределённый исход:
допустимая вариативность и исключения должны быть согласованы и описаны.
«Корректно», «быстро», «при необходимости», «и/или», «и т. д.» без определения —
повод уточнить правило. Краткость не оправдывает потерю условий.
5. **Величины и границы.** Для расчёта задай входы, формулу, знак, валюту, точность
и момент округления, если они влияют на результат. Для периода — включительность
границ, дату/время учёта и часовой пояс; для порога — сравнение и точку отсчёта.
Различай ноль, отсутствие значения и неизвестный результат. Не добавляй допуск,
таймаут или округление без источника только ради измеримости.
6. **Свобода реализации.** Описывай «что», а не внутренние классы, библиотеки или
алгоритм. Если внутренний механизм можно заменить без изменения внешнего
обязательства, это design. Внешний формат или протокол допустим в Specs, когда
именно он является согласованным обязательством, а не удобством реализации.
## Requirement and Scenario
Порядок блока: `### Requirement:` с устойчивым именем → общая норма → нужные
определения/таблица → `#### Scenario:`. Для новых требований используй короткое имя
на русском; существующие headings сохраняй для native delta-сопоставления.
Выбирай форму по смыслу (адаптация EARS, не дополнительный синтаксис OpenSpec):
| Смысл | Каркас нормы |
| --- | --- |
| Постоянное свойство | Система SHALL … |
| Реакция на событие | При … система SHALL … |
| Зависимость от состояния | Пока … система SHALL … |
| Ошибка или исключение | Если … система SHALL … |
У ADDED/MODIFIED Requirement минимум один Scenario. GIVEN нужен, когда исходное
состояние влияет на результат; WHEN задаёт событие; THEN — наблюдаемый исход с
определённым ожидаемым значением или составом, не «работает правильно». AND продолжает
предыдущий тип шага: дополняет исходный контекст, событие или ожидаемый исход.
Шаги не описывают внутренние вызовы и селекторы UI.
Сначала общее правило, затем различающие примеры: основной путь, существенная
граница, иной исход, отказ, повтор или конкуренция — по применимости. Каждый сценарий
добавляет новый класс поведения. Не вводи правило только в Scenario и не переписывай
правило целиком для каждой комбинации данных. Сценарии показывают следствия нормы,
но несколько примеров не доказывают универсальный инвариант.
## Representation
| Когда прозы недостаточно | Представление и обязательные уточнения |
| --- | --- |
| Взаимодействуют несколько условий | Таблица решений: область входов, смысл «любое», политика совпадений (одна строка / первая / все), результат отсутствия совпадений или объяснение полноты |
| Результат зависит от истории | Переходы: исходное состояние, событие/условие, новое состояние, видимый эффект; существенные повторы и недопустимые переходы |
| Должно сохраняться свойство | Инвариант/формула с областью действия и единицами; примеры нуля, знака и округления по применимости |
Нормативная таблица находится внутри Requirement до первого Scenario и вводится
фразой с SHALL. Она — единственное определение правила, а не копия соседней прозы.
Неоднозначные пересечения строк — дефект, если явно не задан приоритет или сбор
результатов. Таблица безусловно не нужна каждому требованию. Внутренние состояния
реализации и межсистемные схемы остаются в analysis/design.
## Examples
Учебные примеры формы, не продуктовые решения WealthLedger. В реальном change
периоды, состояния и исходы берутся из его согласованных входов.
### Condition and Boundary
```markdown
## ADDED Requirements
### Requirement: Отбор операций за календарный период
При формировании отчёта система SHALL включать все и только операции выбранного
счёта, дата учёта которых входит в выбранный период включительно по Europe/Moscow.
#### Scenario: Последний день периода и следующий день
- **GIVEN** выбран период 01.10.2026–31.10.2026 и счёт A
- **AND** на счёте A есть операции X с датой учёта 31.10.2026 и Y с датой 01.11.2026
- **WHEN** формируется отчёт
- **THEN** отчёт включает X и не включает Y
```
Плохо: «Система SHALL корректно отбирать операции до конца месяца» — не определены
месяц, границы, дата отбора и счёт. «THEN список корректен» не позволяет проверить исход.
### Decision Table
```markdown
## ADDED Requirements
### Requirement: Различимость неполного и пустого итога
Система SHALL представлять итог расчёта по таблице. «Полнота» означает получение
всех данных выбранной выборки; «есть записи» — наличие хотя бы одной записи.
Оба условия логические. Совпадает ровно одна строка; «любое» означает оба значения.
Покрыты все комбинации.
| Полнота подтверждена | Есть записи | Видимый итог |
| --- | --- | --- |
| Нет | Любое | Недоступен, без числового итога |
| Да | Нет | Ноль и пояснение пустого результата |
| Да | Да | Рассчитанный итог |
#### Scenario: Известная часть не становится окончательной суммой
- **GIVEN** известна одна запись на 100 RUB, но полнота выборки не подтверждена
- **WHEN** пользователь просматривает результат
- **THEN** итог обозначен как недоступный, не показан ни как 100 RUB, ни как ноль
```
Плохо: строки «нет данных → 0» и «данные не полны → недоступно» без определения
«нет данных» — одному входу могут соответствовать два разных результата.
### State Transition
```markdown
## ADDED Requirements
### Requirement: Отмена формирования отчёта
При запросе отмены система SHALL изменять состояние формирования отчёта по таблице.
Область правила — состояния «Ожидает», «Отменён», «Готов»; начальное — «Ожидает».
Совпадает ровно одна строка. Таблица определяет только событие отмены.
| До | После | Результат отмены |
| --- | --- | --- |
| Ожидает | Отменён | Отмена подтверждена, отчёт не будет сформирован |
| Отменён | Отменён | Отмена подтверждена без повторного эффекта |
| Готов | Готов | Отмена отклонена, готовый отчёт сохранён |
#### Scenario: Повтор отмены
- **GIVEN** формирование отчёта уже отменено
- **WHEN** пользователь снова запрашивает отмену
- **THEN** отмена подтверждена, состояние остаётся «Отменён»
```
Плохо: «повтор ничего не делает» — не определён результат для пользователя.
## Review
Используй два прохода на существующем gate Specs (G2 или G3), не новый gate.
Автор выполняет самопроверку; назначенный по режиму reviewer перепроверяет независимо.
| Проход | Что проверить |
| --- | --- |
| Нормоконтроль | Native headings/операции; термины и единицы; явные субъект/условие/исход; атомарность; корректные ссылки; Scenario с проверяемым THEN |
| Смысл и набор | Необходимость и происхождение норм (у спорных/выведенных — источник действительно подтверждает норму, ссылка пригодна после archive); основной путь, альтернативы, отказы и границы; повторы/конкуренция/сохранность по риску; непротиворечивость таблиц и итогового baseline; отсутствие выдуманных решений |
Проверь не только delta, но и получающийся набор вместе с canonical specs:
сохранились ли действующие ограничения, исключения, инварианты и сценарии? MODIFIED
заменяет полный блок; редакторское улучшение не разрешает удалить прежний сценарий
или незаметно переименовать Requirement.
Профиль не отменяет ограничений установленного CLI. При предупреждении о длине
сократи повторения или раздели действительно независимые обязательства; не убирай
условия и не прячь норму в комментарий/код ради успешного `--strict`.
Контрольный вопрос: «Назови существенную ошибочную реализацию и наблюдение, которым
это требование отличит её от соответствующей». Если ответ зависит от догадки,
укажи точную неопределённость. Это эвристика ревью, не доказательство полноты.
Finding: **файл/heading + цитата → нарушенное правило → два возможных толкования
или недостающий исход → вопрос владельцу/адресат исправления**. Reviewer возвращает
находки, не переписывает текст по вкусу. Пример: «до конца месяца» → неясна правая
граница → операция в последний день включается или исключается → уточнить в Specs.
Нерешённый вопрос, меняющий требование или ожидаемый результат, блокирует его gate;
косметическое предпочтение не блокирует. Validation и Findings — в существующем
Gate Log, без нового отчёта на каждый Requirement.
### Trial
Эффект профиля проверяется сравнительным пилотом; до результатов он не подтверждён.
1. До запуска зафиксируй четыре одинаковых для обеих версий задания: простое правило,
ветвление, повтор/состояние и MODIFIED с исходной canonical spec. Для оценки отдельно
запиши согласованные исходы, намеренно оставленные неизвестные и контрольные вопросы.
2. Сравни прежнюю и изменённую инструкции минимум на двух авторах/моделях, каждый
выполняет обе версии. Сохрани входы, prompts, версии инструкций и моделей, доступные
настройки и результаты. Для LLM используй отдельные сессии без ответов другой версии;
для людей чередуй порядок версий, чтобы уменьшить эффект обучения.
3. Независимые читатели отвечают на одинаковые контрольные вопросы по полученным
спецификациям, не зная автора и версии инструкции. Ответы для оценки из шага 1
раскрываются после чтения; согласие читателей само по себе не означает правильность.
4. По каждому заданию сравни расхождения трактовок, ошибки относительно согласованных
исходов, пропущенные случаи, выдуманные нормы и время уточнения/ревью. Сохрани
конкретные примеры ошибок вместе с результатами в Issue пилота.
5. Критерий улучшения согласуй до запуска: меньше смысловых расхождений без роста
ошибок, пропусков и выдуманных норм; допустимое изменение времени определяет владелец.
Если различий нет или данных недостаточно, эффект не подтверждён. Вывод ограничен
проверенными заданиями и авторами/моделями. Совпадение формулировок и успешный CLI
не считаются доказательством одинакового понимания.
## Sources and Adaptation
Из девяти исследований `sdd-spec-quality/*.md` и трёх сводок `result/*.pdf` выбраны
правила с понятной пользой для ручного Markdown-процесса. Первоисточники проверены
30.10.2026; критерии адаптированы, а не перенесены как обязательная сертификация.
| Источник | Что взято и где предел |
| --- | --- |
| [NASA SE Handbook, Appendix C, C.2–C.4](https://www.nasa.gov/reference/appendix-c-how-to-write-a-good-requirement/) | Необходимость, одна мысль, «что, а не как», проверяемость и согласованность; не сертификационный комплект документов |
| [INCOSE GtWR v4 Summary, с. 2–3](https://www.incose.org/wp-content/uploads/legacy/working-groups/requirements-wg/guidetowritingrequirements/incose_rwg_gtwr_v4_summary_sheet.pdf) | C1/C3/C5/C7 для отдельного требования, C10–C14 для набора; R4/R7/R33–R36 — термины, величины, время. Полный платный Guide не заявлен как проверенный |
| [EARS, автор методики](https://alistairmavin.com/ears/) | Каркасы условия/события/состояния/ошибки; русский язык — адаптация, не буквальная английская грамматика |
| [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html), [8174 §2](https://www.rfc-editor.org/rfc/rfc8174.html#section-2) | Значения uppercase SHALL/MUST и запретов; наличие ключевого слова само по себе не гарантирует качество |
| [Cucumber, Given/When/Then](https://cucumber.io/docs/gherkin/reference/) | Исходное состояние, событие, наблюдаемый исход; Markdown Scenario не становится исполняемым Gherkin-тестом |
Из предложений не приняты: жёсткие лимиты слов/сценариев, подсчёт SHALL вместо
атомарности, обязательные REQ-ID и скрытые атрибуты, JSON-first, новые линтеры и gates.
Из нормоконтроля конструкторской документации заимствовано разделение проверки формы
и смысла; соответствие ГОСТ, DO-178C или ISO/IEC/IEEE 29148 не заявляется. Таблицы и
инварианты используются по риску; обязательная формальная модель каждому change не нужна.
@@ -0,0 +1,29 @@
# Data Mapping
<!-- Преобразование данных между системами по полям. Большой mapping — машиночитаемый
файл в contracts/ со ссылкой отсюда. -->
## Source and Target
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
| --- | --- | --- | --- | --- |
## Field Mapping
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
| --- | --- | --- | --- | --- | --- | --- | --- |
## Invariants
<!-- Правила сохранения после mapping, например «итог = сумма позиций + кэш; каждая
денежная единица учтена один раз», знаки, точка округления. Каждый инвариант
ссылается на защищаемый Requirement. -->
## Double-counting and Classification Risks
<!-- Каждый тип исходной записи, который может попасть в два агрегата, и правило,
относящее его ровно к одному. -->
## Transformation Errors
## Open Questions
@@ -0,0 +1,58 @@
# Engineering Design
<!-- Как affected repositories реализуют утверждённые specs и системные обязательства.
Ссылайся на upstream ids, не пересказывай. -->
## Inputs
<!-- Точные upstream-артефакты, которые реализует этот design (specs, выбранные
модули analysis/, ux-design, security-analysis). -->
## Architecture Drivers
<!-- Requirements, ids NFR, ограничения и риски, которые вынуждают решения. -->
## Technical Goals / Non-Goals
## Implementation Impact Map
| Область | Затронута | Результат или причина N/A |
| --- | --- | --- |
| main | | |
| frontend | | |
| backend | | |
## Decisions
<!-- На каждое решение: контекст, выбранный вариант, отвергнутые альтернативы, driver.
Группируй по repository, если это помогает. -->
## Components and Responsibilities
## Data / Storage Changes
## Contract Implementation
<!-- Как каждый контракт производится/потребляется и как опубликованный OpenAPI
сравнивается с target-контрактом. N/A, если контракты не менялись. -->
## Cross-cutting Implementation
## UI Implementation
<!-- С ux-design: композиция компонентов, state, data fetching, как получается каждое
UI-состояние — без пересказа UX. Без ux-design: new/changed/reuse или N/A с
причиной и нужные детали экранов/состояний. -->
## Developer Verification Strategy
## Migration / Rollout / Rollback
## Solution Assessment
<!-- Обязателен для нового auth, security, storage, интеграции, observability,
кеширования, конкурентности или общего tooling; иначе N/A с причиной. -->
## Risks / Trade-offs
## ADR Candidates
@@ -0,0 +1,18 @@
# Documentation Impact
<!-- Только план; docs/ правят tasks `[docs]`. -->
## Summary
## Documentation Changes
| Action | Target | Category | Итоговое AS-IS | Source | Verification |
| --- | --- | --- | --- | --- | --- |
## Diagram Promotion
## ADR Decision
## Glossary / Navigation Impact
## No-Change Rationale
@@ -0,0 +1,25 @@
# Failure Model
<!-- Семантика отказов на границах. Реализация механизмов (очереди, locks,
библиотеки) — в design.md. -->
## Failure Scenarios
| ID | Граница / шаг | Отказ | Обнаружение | Требуемое поведение системы | Наблюдаемый результат | Requirement |
| --- | --- | --- | --- | --- | --- | --- |
## Timeouts and Retries
## Idempotency and Duplicate Handling
<!-- Ключ идемпотентности, scope, срок хранения; что даёт повторный запрос/событие. -->
## Ordering and Concurrency
## Partial Failure and Compensation
## Recovery Behavior
## Compatibility During Failure
## Open Questions
@@ -0,0 +1,16 @@
# Non-Functional Requirements
<!-- Только измеримые ограничения, которые меняют design или проверку. У каждой
строки есть согласованный порог с единицами, условия/нагрузка и способ измерения.
Для времени укажи точки начала/конца, для статистики — окно и агрегат.
Не превращай пример числа из исследования в утверждённую цель. -->
| ID | Категория | Требование | Порог и единицы | Условия измерения | Измерение / evidence | Обоснование / источник |
| --- | --- | --- | --- | --- | --- | --- |
## Constraints Without Thresholds
<!-- Реальные, но пока неизмеримые ограничения: почему нет порога, кто решит и какое
решение/проверку это блокирует. Не выдавай неизвестное за согласованный NFR. -->
## Open Questions
@@ -0,0 +1,23 @@
# Observability
<!-- Что должно быть наблюдаемо и зачем; не какой SDK, exporter или logger (design.md). -->
## Semantic Events
| Событие | Trigger | Обязательные атрибуты | Потребитель / назначение |
| --- | --- | --- | --- |
## Logs, Metrics and Traces
## Correlation
## SLI and Alert Conditions
| SLI | Определение | Цель | Условие алерта |
| --- | --- | --- | --- |
## Diagnostic Scenarios
<!-- «Когда X, оператор определяет Y по Z». -->
## Open Questions
@@ -0,0 +1,69 @@
# Process Record
**Mode:** <!-- lab | delivery -->
**Class:** <!-- standard | complex -->
**Issue:** <!-- ссылка на Gitea Issue -->
## Change Profile
<!-- Строка на каждый условный артефакт, все десять обязательны. Выбран = yes|no.
Причина называет конкретный trigger из instruction артефакта или объясняет, почему
он не применим; «lab: практика …» допустима только в lab и называет практику.
Пример:
| failure-model | yes | синхронизация брокера повторяет неидемпотентную запись |
| nfr | no | нет новых обязательств по задержке, объёму или хранению |
| data-mapping | yes | lab: практика data mapping |
Плохо: `| nfr | no | N/A |` — причина должна объяснять, почему не нужно. -->
| Артефакт | Выбран | Причина |
| --- | --- | --- |
| research | | |
| ux-design | | |
| system-analysis | | |
| data-mapping | | |
| failure-model | | |
| nfr | | |
| observability | | |
| security-analysis | | |
| test-cases | | |
| docs-impact | | |
## Gate Log
<!-- Только дописывание, по хронологии. Scope — ids артефактов в решении.
Validation — выполненные команды и результаты, вердикты review.
Decision — `approved "<дословные слова владельца>"`, `rejected`,
`changes requested` или `invalidated: <причина>`. Никогда не записывай решение,
которого владелец не давал. ПОСЛЕДНЯЯ строка gate — его состояние; более поздняя
rejected / changes requested / invalidated снимает этот gate и все последующие.
Для Specs: Validation отмечает нормоконтроль и смысловую проверку набора по
spec-writing.md; Findings — конкретные неясности/пропуски и их разрешение.
Пример:
| G1 | 2026-10-29 | proposal, change-profile | `openspec validate` OK; readiness: plan OK | product-analyst: 1 major — закрыт в Open Decisions | approved "ок, идём дальше" (сессия) |
| G2 | 2026-10-30 | specs | `openspec validate --strict` OK; readiness: plan OK | qa-engineer: в Scenario нет значения THEN | changes requested |
Плохо: `approved` без цитаты владельца; строка G4 со Scope `design`. -->
| Gate | Дата | Scope | Validation | Findings | Decision |
| --- | --- | --- | --- | --- | --- |
## Descoped Work
<!-- Tasks, удалённые из tasks.md после G3. Причина и gate решения обязательны.
Если ничего не снято — только строка заголовка. -->
| Task | Причина | Gate |
| --- | --- | --- |
## Lab Journal
<!-- lab: строка на каждую завершённую стадию, сразу после неё. Уникальные находки —
то, что нашла только эта стадия. Стоимость — вызовы сабагентов, примерно токены
или время. delivery: необязательно. -->
| Стадия | Роль / сабагент | Уникальные находки | Решения | Риски найдены / исключены | Проблемы передачи | Skills | Стоимость | Переносимо |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
## Retrospective
<!-- После G4. Какие стадии окупились, какие были лишними для этого класса change,
что поменять в schema. Сначала факты, потом мнение. -->
@@ -0,0 +1,39 @@
# Proposal
<!-- Язык Change Owner. Без файлов, API, таблиц, библиотек, диаграмм и тест-планов. -->
## Why
## What Changes
## Scope
### In Scope
### Out of Scope / Non-Goals
## Capabilities
### New Capabilities
### Modified Capabilities
## Impact
## Success Criteria
<!-- Каждый критерий измерим или наблюдаем: что увидит пользователь или владелец. -->
## Evidence
<!-- Факты, обосновывающие change, каждый с источником: Issue, research (ссылка или
`analysis/research.md`), инцидент, измерение. Гипотезы помечай явно. -->
## Assumptions / Dependencies
## Open Decisions
<!-- Ещё не принятые решения, способные изменить scope. Каждое: вопрос, кто решает,
зависимое требование/артефакт и что блокируется до ответа. Несогласованное
правило, термин, порог или вариант поведения не заменяй допущением в Specs.
Пусто, только если таких вопросов нет. -->
@@ -0,0 +1,45 @@
# QA Analysis
<!-- Risk-based стратегия системной проверки. Ссылайся на Scenarios, не пересказывай.
Без планов developer unit/component tests. -->
## Risk Assessment
| Риск | Затронутые требования | Вероятность | Ущерб | Глубина проверки |
| --- | --- | --- | --- | --- |
## Coverage Matrix
<!-- Строка на каждый ADDED/MODIFIED/REMOVED Requirement. Назови покрытые Scenarios
точными headings и ветви таблицы/переходы, если они есть. Проверка даёт наблюдение,
отличающее исполнение нормы от нарушения; test cases — ссылками, не копиями.
Пропуск случая — «not verified: <риск-причина>» с названием случая.
Покрытие — только строки этой таблицы; упоминание требования в другом месте
файла покрытием не считается. REMOVED требует проверки миграции/регрессии.
При skip_specs: true вместо дельт укажи существующее поведение/ограничение,
которое сохраняем, точный источник и регрессионную проверку.
Пример:
| `example-report` — `Отбор операций за календарный период` | Последний день периода и следующий день | Отчёт за октябрь включает X за 31.10 и исключает Y за 01.11 | API | ссылка на Scenario | -->
| Requirement / регрессионный baseline | Сценарии / ветви правила | Проверка / ожидаемое наблюдение | Уровень | Test cases |
| --- | --- | --- | --- | --- |
## Negative and Boundary Focus
<!-- Границы до/на/после порога, отказ, повтор, конкуренция, сохранность данных —
только применимое. Ссылайся на нормы, не придумывай исходы. -->
## Cross-system Verification
## Test Data and Environment Needs
## Manual / Exploratory Scope
## Automation Candidates
## Regression Impact
## Gaps / Blockers
<!-- Цитата/heading → неоднозначность или непокрытый исход → владелец решения и
upstream-артефакт. Вопрос о значении expected result блокирует согласование. -->
@@ -0,0 +1,28 @@
# Product Research
<!-- Входной контекст, не источник требований. Каждое утверждение помечено:
[F] факт с источником, [I] интерпретация, [H] гипотеза. -->
## Problem Statement
## Target Audience
## Research Questions
## Existing Solutions and Alternatives
| Вариант | Что делает | Сильные стороны | Слабые стороны | Источник |
| --- | --- | --- | --- | --- |
## Evidence & Sources
## Assumptions
## Risks
## Conclusions
## Recommendation
<!-- делать / не делать / сузить scope / исследовать дальше — с причинами.
«Не делать» — допустимый итог. -->
@@ -0,0 +1,26 @@
# Security Analysis
<!-- Threat model только этого change. На security-NFR ссылайся по id, не
пересказывай. Без универсального чек-листа. -->
## Scope
## Assets
## Trust Boundaries
## Threats
| ID | Угроза (STRIDE) | Сценарий атаки | Актив / граница | Существующий контроль | Вероятность / ущерб |
| --- | --- | --- | --- | --- | --- |
## Recommended Mitigations
| Угроза | Mitigation | Артефакт-владелец (specs / system-analysis / design) |
| --- | --- | --- |
## Residual Risks
## Validation Requirements
<!-- Evidence, нужные до G4: фокус review, негативные тесты, инструменты. -->
@@ -0,0 +1,48 @@
<!--
Перед заполнением прочитай единый профиль:
openspec/schemas/wealth-ledger-sdd-lab/spec-writing.md.
Оставь только нужные этой дельте разделы; подсказки и образец не входят в результат.
Файл — specs/<capability-path>/spec.md с точным путём из Proposal.
Только новая capability: `## Purpose` в 1–2 предложения (от 50 символов).
Существующая capability: Purpose не добавляй.
Операции:
- `## ADDED Requirements` — новые полные блоки Requirement.
- `## MODIFIED Requirements` — полный существующий блок со всеми сохраняемыми
Scenario, затем изменённое поведение; heading совпадает точно.
- `## REMOVED Requirements` — heading Requirement плюс **Reason** и **Migration**.
- `## RENAMED Requirements` — только пары `FROM:` и `TO:`.
Каркас ADDED/MODIFIED: устойчивое имя → субъект SHALL <наблюдаемый результат>
с явным условием, если оно нужно → определения/таблица → Scenarios.
SHALL NOT — запрет; существующие MUST/MUST NOT равнозначны. Один Requirement —
одно независимо принимаемое обязательство, не подсчёт предложений или SHALL.
Сценарий: GIVEN — существенное исходное состояние, WHEN — событие,
THEN — конкретный наблюдаемый исход. Минимум один Scenario; границы/отказы/повторы
добавляй по смыслу. Новое правило в одном лишь сценарии недопустимо.
AND дополняет предыдущий тип шага GIVEN/WHEN/THEN.
Термины определены, величины имеют единицы и границы. Неизвестное — вопрос в
Open Decisions с владельцем и зависимым требованием, не придуманный ответ.
Для спорного/выведенного Requirement укажи источник перед первым Scenario по профилю.
Пример формата (подставь реальное согласованное решение):
**Источник (ненормативно):** [решение владельца](<URL-комментария-Issue>), пункт «Границы периода».
Ссылка остаётся пригодной после archive; норму полностью сформулируй в Requirement.
Учебный образец формы, не продуктовая политика:
## ADDED Requirements
### Requirement: Отбор операций за календарный период
При формировании отчёта система SHALL включать все и только операции выбранного
счёта, дата учёта которых входит в выбранный период включительно по Europe/Moscow.
#### Scenario: Последний день периода и следующий день
- **GIVEN** выбран период 01.10.2026–31.10.2026 и счёт A
- **AND** на счёте A есть операции X с датой учёта 31.10.2026 и Y с датой 01.11.2026
- **WHEN** формируется отчёт
- **THEN** отчёт включает X и не включает Y
Плохо: «корректно отбирать операции до конца месяца» — не определены месяц,
границы, дата отбора и счёт; «THEN список корректен» — нет проверяемого исхода.
Для таблиц решений и переходов используй эталоны из профиля.
-->
@@ -0,0 +1,49 @@
# System Analysis
<!-- Hub. Владеет контекстом, границами, обзором flows и семантикой контрактов;
детали — в выбранных модулях, на них ссылаются, а не копируют. -->
## Requirement Coverage
| Requirement | Flow / контракт | Модуль |
| --- | --- | --- |
## System Context and Boundaries
## As-Is / To-Be
## Impact Analysis
## Systems and Responsibilities
## Data Ownership
| Данные | Источник истины | Пишут | Читают |
| --- | --- | --- | --- |
## Interaction Flows
<!-- Ссылки на diagrams/*.mmd; текст — только для того, что диаграмма не передаёт. -->
## Contracts
<!-- На каждый контракт: producer/consumer, операция/событие, версия, файл в
contracts/, класс совместимости (additive / breaking), опубликованный источник,
с которым он обязан совпасть после реализации. -->
| Контракт | Producer → Consumer | Файл | Совместимость | Опубликованный источник |
| --- | --- | --- | --- | --- |
## Error Semantics
<!-- Бизнес- и технические отказы на границах. Подробные retry/идемпотентность/
восстановление — в failure-model.md, если выбран. -->
## Compatibility / Migration
## Detailed Modules
<!-- Ссылка на каждый выбранный модуль (data-mapping, failure-model, nfr,
observability) и одна строка о том, что он решает. -->
## Open Questions
@@ -0,0 +1,24 @@
# Tasks
<!-- Формат task:
- [ ] X.Y [main|frontend|backend|docs|specs] <Kind>: <действие + результат>
- **Covers:** пары `capability` — `Requirement heading` или `N/A` — <причина>
- **Source:** точный раздел upstream-артефакта / контракт / запись docs-impact
- **Verification:** команда владельца, наблюдаемая проверка или артефакт
- **Depends on:** ids tasks или none
Kinds: Implementation, Migration, Automated Testing, Contract Parity, Docs,
Independent Review, QA Verification, Verification.
Пример:
- [ ] 2.1 [backend] Implementation: пропускать операции, чей внешний id уже сохранён
- **Covers:** `portfolio/holdings` — `Итог портфеля не учитывает дубли операций`
- **Source:** design.md Decisions D2; analysis/failure-model.md F3
- **Verification:** `npm run verify` в backend; повторный импорт fixture оставляет итог 100.00
- **Depends on:** 1.1
Плохо: «Verification: проверить, что работает»; Covers с heading, которого нет в specs. -->
## 1. <!-- этап delivery -->
## Docs Impact
<!-- Только если docs-impact не выбран: targets в docs/ с итоговым AS-IS или одна
конкретная причина без изменений. Удали раздел, если есть analysis/docs-impact.md. -->
@@ -0,0 +1,16 @@
# Test Cases — <!-- область -->
<!-- Один файл на область в analysis/test-cases/. Case добавляет конкретные данные,
шаги или oracle, которых нет в Scenario; иначе qa-analysis ссылается на Scenario,
а case не пишется. -->
### TC-<!-- AREA-NNN --> <!-- название -->
- **Verifies:** `capability` — `Requirement heading` / Scenario
- **Risk / Priority:**
- **Preconditions:**
- **Test Data:**
- **Steps:**
1.
- **Expected Results:**
- **Automation Candidate:** <!-- yes (уровень) | no (причина) -->
@@ -0,0 +1,39 @@
# UX Design
<!-- Interaction design observable behavior из Delta Specs. Ссылайся на требования
как `capability` — `Requirement heading`, не пересказывай. Без серверной
архитектуры, контрактов и выбора компонентов/state management. -->
## User Goals
## User Flows
<!-- Mermaid flowchart или нумерованные шаги на каждый flow, с точками входа и выхода. -->
## Information Architecture and Screen Structure
## Interaction Behavior
## UI States
| Экран / область | Loading | Empty | Error | Success | Partial / stale |
| --- | --- | --- | --- | --- | --- |
## Validation Feedback
## Responsive Behavior
## Accessibility
<!-- Путь с клавиатуры, порядок фокуса, имена/роли, контраст, признаки не только цветом. -->
## Design System Reuse
## Materials
<!-- По умолчанию inline Markdown; локальные файлы — по правилу Visual Package. -->
| ID материала | Относительный путь / ссылка | Экран/состояние и назначение | SHA-256 |
| --- | --- | --- | --- |
## Open UX Questions
@@ -1,306 +0,0 @@
# agent-sdd-session-workflow Specification
## Purpose
Определяет воспроизводимый session-oriented workflow для AI-assisted SDD,
сохраняющий OpenSpec единственным источником спецификации и границы независимых
инженерных ролей.
## Requirements
### Requirement: Каждая agent-сессия имеет одну SDD-роль
Session-oriented SDD workflow SHALL направлять каждую agent-сессию ровно в одну
роль: Discovery, Specification, Implementation, Validation или Finalization.
Сессия MUST NOT самостоятельно выполнять следующую роль, но создание immutable
phase record или ожидание решения MUST NOT само по себе принуждать к новой
сессии той же роли. Specification MUST поддерживать bounded progression
`Proposal + Delta Specs` и `Design + Tasks`, сохраняя отдельную explicit
approval boundary перед каждым downstream artifact. Для `wealth-ledger-sdd-v2`
Specification SHALL также поддерживать System Analysis после Delta Specs,
параллельные Design и QA Analysis после System Analysis, Docs Impact после
обоих и Tasks после Docs Impact. Для active `wealth-ledger-sdd` сохраняются
прежние пары и approvals без автоматической миграции.
#### Scenario: Начинается новый change
- **WHEN** пользователь начинает SDD-workflow для нового change
- **THEN** workflow направляет сессию только в Discovery и не начинает
Specification, Implementation, Validation или Finalization в той же сессии
#### Scenario: Specification подготавливает planning artifact
- **WHEN** Specification подготовила structurally valid Proposal
- **THEN** workflow запрашивает явный approval Change Owner и не создаёт Delta
Specs до получения решения
#### Scenario: Proposal явно одобрен
- **WHEN** Change Owner явно одобряет неизменившийся Proposal в bounded
`Proposal + Delta Specs` progression
- **THEN** Specification MAY продолжить в той же роли и создать Delta Specs без
перехода к Design или другой SDD-роли
#### Scenario: Delta Specs явно одобрены
- **WHEN** Change Owner явно одобряет неизменившиеся Delta Specs
- **THEN** workflow завершает bounded `Proposal + Delta Specs` progression и
направляет следующий Specification outcome к Design для legacy либо к System Analysis для v2
#### Scenario: Design явно одобрен
- **WHEN** Change Owner явно одобряет неизменившийся Design в bounded
`Design + Tasks` progression
- **THEN** для legacy Specification MAY продолжить в той же роли и создать Tasks;
для v2 Tasks ожидает также QA Analysis и Docs Impact; Implementation ждёт отдельного approval Tasks
#### Scenario: Завершилась реализация
- **WHEN** Implementation завершает все утверждённые implementation-owned Tasks
- **THEN** workflow фиксирует результат Implementation как вход для следующей
Validation-сессии и не выполняет Validation в завершившейся
Implementation-сессии
### Requirement: Продолжение workflow восстанавливает только нужный контекст
Workflow SHALL однозначно определять текущую роль и разрешённый outcome из
сохранённого состояния change и одного актуального self-contained handoff. Он
MUST загружать только phase-specific canonical authority и изменившийся
relevant evidence, а не полную историю, прежние handoff-данные или уже
прочитанные неизменившиеся inputs. После появления OpenSpec change его approved
artifacts MUST быть источником intent, requirements, design и tasks.
Specification при подготовке Design SHALL определять применимый визуальный
пакет в canonical Design. При продолжении после его approval Implementation и
Validation SHALL получать применимые визуальные материалы именно согласованной
версии из canonical Design, сохраняя одну роль сессии и не создавая копию Design
в handoff. Недоступность или неоднозначность согласованного пакета MUST быть
сообщена как блокер зависимой работы и MUST NOT компенсироваться догадкой по
истории обсуждения или текущему внешнему макету.
#### Scenario: Пользователь продолжает change после другой сессии
- **WHEN** пользователь запрашивает продолжение известного change
- **THEN** workflow определяет ровно одну текущую роль, читает только актуальный
handoff и применимую canonical authority этой роли
#### Scenario: Есть история нескольких попыток
- **WHEN** change содержит более старые records или результаты прежних попыток
- **THEN** workflow не загружает их по умолчанию и обращается к ним только для
расследования конкретной проблемы текущей роли
#### Scenario: Независимые evidence доступны одновременно
- **WHEN** до следующего смыслового решения нужны несколько независимых
read-only inputs
- **THEN** workflow получает их одним bounded batch и сохраняет полный relevant
scope для failure или review findings
#### Scenario: Успешный gate уже выполнен на неизменившихся inputs
- **WHEN** успешный gate был выполнен и его relevant inputs после этого не
изменялись
- **THEN** workflow использует сохранённый observed outcome и не повторяет gate
без причины
#### Scenario: Specification готовит Design с визуальными материалами
- **WHEN** Specification получила разрешение подготовить Design с применимым
UI/UX scope
- **THEN** она представляет визуальные материалы как часть одного canonical
Design и останавливается до его единого approval, не начиная другую роль
#### Scenario: Implementation получает утверждённый визуальный Design
- **WHEN** Implementation начинает разрешённую реализацию UI change
- **THEN** она использует применимый пакет согласованной версии из canonical
Design, а навигационный handoff не заменяет его содержимое или owner approval
#### Scenario: Пакет невозможно однозначно восстановить
- **WHEN** следующая роль не может получить применимые материалы или установить
согласованную версию
- **THEN** зависимая работа останавливается с конкретным блокером до
восстановления пакета либо разрешения вопроса на соответствующей planning boundary
### Requirement: Handoff не заменяет canonical OpenSpec authority
Workflow handoff SHALL сохранять только состояние перехода, краткий observed
result, известные блокеры и один следующий допустимый outcome. Navigation state
MAY сохранять approval status текущих planning artifacts для продолжения
workflow, но handoff и state MUST NOT становиться альтернативным хранилищем
Requirements, Delta Specs, Design, Tasks или acceptance scenarios и MUST NOT
считаться самостоятельным доказательством решения Change Owner.
#### Scenario: Specification уже завершена
- **WHEN** следующая роль получает handoff после Specification
- **THEN** она использует approved OpenSpec artifacts для требований и решений,
а handoff — только для маршрутизации, observed results и известных
ограничений
#### Scenario: Approval подтверждён для неизменившегося artifact
- **WHEN** новый Specification context содержит сохранённый approved status и
workflow не изменял соответствующий planning artifact
- **THEN** workflow продолжает к разрешённому downstream artifact без
повторного запроса того же approval
#### Scenario: Workflow изменяет planning artifact
- **WHEN** workflow семантически изменяет planning artifact
- **THEN** approval этого artifact и всех зависимых downstream artifacts
сбрасывается до нового явного решения Change Owner
#### Scenario: Требование нужно изменить во время реализации
- **WHEN** Implementation обнаруживает, что requirement или архитектурное
решение неполно либо противоречиво
- **THEN** workflow возвращает работу к самой ранней затронутой canonical
planning boundary и не изменяет требование только в handoff или реализации
### Requirement: Validation остаётся независимой и без побочных исправлений
Validation SHALL независимо выполнять affected canonical gates, semantic и
acceptance verification, проверку применимых edge cases и independent read-only
review относительно approved OpenSpec artifacts. Она MUST NOT изменять
production implementation, specification или Tasks и MUST возвращать только
PASS, FAIL либо SPEC_ISSUE с маршрутизацией к Finalization, Implementation либо
самой ранней затронутой planning boundary. Validation-owned verification и
review MUST NOT оставаться unchecked implementation Tasks.
Для применимого UI/UX scope независимое сопоставление SHALL включать визуальные
материалы согласованной версии canonical Design и соответствующие экраны,
состояния, взаимодействия и адаптивное поведение реализации. Read-only boundary
Validation SHALL распространяться на весь визуальный пакет Design.
#### Scenario: Реализация соответствует утверждённому change
- **WHEN** Validation подтверждает approved requirements, affected gates,
acceptance behavior и отсутствие blocking review findings
- **THEN** она возвращает PASS и разрешает следующую сессию Finalization
#### Scenario: Найдена проблема реализации
- **WHEN** Validation находит несоответствие реализации утверждённому OpenSpec
- **THEN** она возвращает FAIL с точным observed finding и направляет следующую
сессию к Implementation без самостоятельного исправления
#### Scenario: Найдена проблема спецификации
- **WHEN** Validation находит противоречие или пробел в approved planning
artifacts
- **THEN** она возвращает SPEC_ISSUE и направляет работу к самой ранней
затронутой planning boundary без переписывания specification под реализацию
#### Scenario: Tasks планируют независимую проверку
- **WHEN** Tasks описывают ожидаемую semantic verification или independent
review после реализации
- **THEN** завершение Implementation не зависит от checkbox, который может
закрыть только Validation
#### Scenario: Найдено визуальное несоответствие
- **WHEN** наблюдаемая реализация расходится с однозначным применимым решением
согласованного визуального пакета
- **THEN** Validation возвращает FAIL с конкретным несоответствием и не
исправляет интерфейс или визуальный пакет в этой сессии
#### Scenario: Визуальный пакет противоречит другим planning artifacts
- **WHEN** Validation обнаруживает противоречие согласованного визуального
результата требованиям либо неоднозначность самого Design
- **THEN** она возвращает SPEC_ISSUE с основанием возврата к самой ранней
затронутой planning boundary, не выбирая новую трактовку самостоятельно
### Requirement: Finalization требует успешной независимой Validation
Workflow SHALL разрешать Finalization только после последней независимой
Validation с PASS, если после неё не было relevant изменений planning или
delivery inputs. Finalization MUST выполнять только archive и completion
mechanics, MUST NOT повторять актуальную Validation и MUST направлять каждый
blocker к точной ответственной роли без исправления production implementation
или planning artifacts.
#### Scenario: Последняя Validation прошла
- **WHEN** Finalization получает change после PASS и relevant inputs с момента
этой Validation не изменялись
- **THEN** она использует observed PASS и выполняет completion mechanics без
повторной independent Validation
#### Scenario: Implementation-owned Task не завершена
- **WHEN** Finalization обнаруживает незавершённую implementation-owned Task
- **THEN** она возвращает blocker в Implementation и не архивирует change
#### Scenario: PASS отсутствует или устарел
- **WHEN** последняя Validation не завершилась PASS либо после PASS изменились
relevant planning или delivery inputs
- **THEN** Finalization возвращает blocker в Validation и не считает прежний
outcome актуальным
#### Scenario: Обнаружена planning issue
- **WHEN** Finalization обнаруживает противоречие или пробел в approved planning
artifacts
- **THEN** она возвращает blocker к самой ранней затронутой Specification
boundary без самостоятельного исправления
#### Scenario: Completion mechanics временно недоступны
- **WHEN** production и planning inputs корректны, но собственное retryable
действие завершения не выполнено из-за environment или completion failure
- **THEN** workflow остаётся в Finalization с observed blocker и не повторяет
Implementation или Validation без изменения их inputs
#### Scenario: В Finalization обнаружена проблема
- **WHEN** Finalization обнаруживает любой blocker завершения
- **THEN** она не закрывает lifecycle change, сохраняет observed cause и
направляет продолжение к точной ответственной роли
### Requirement: Implementation сохраняет ограниченные checkpoints
Implementation SHALL выбирать связный ограниченный batch из оставшихся
утверждённых Tasks и MAY завершить сессию, оставаясь в роли Implementation,
если весь change ещё не реализован. Такой checkpoint MUST фиксировать
завершённые canonical Tasks, оставшуюся работу и достаточный результат
релевантных проверок, не выдавая неполный change за готовый к Validation.
#### Scenario: Завершён один связный batch
- **WHEN** Implementation завершила и проверила ограниченный batch, но в
canonical Tasks осталась implementation-owned работа
- **THEN** workflow сохраняет завершённые и оставшиеся Tasks, направляет
продолжение в новую Implementation-сессию и не сообщает blocker или
готовность к Validation
#### Scenario: Завершена вся implementation-owned работа
- **WHEN** все утверждённые implementation-owned Tasks выполнены и релевантные
проверки Implementation успешны
- **THEN** workflow фиксирует готовность change к независимой Validation
### Requirement: Активные инструкции согласованы и независимы от agent runtime
Активные SDD и repository instructions SHALL направлять участника к одной
authority для общей lifecycle policy и одной authority для операционной context
discipline. Они MUST сохранять одинаковые approval и completion safeguards и
MUST NOT требовать конкретный AI-agent runtime или его необязательные
framework-specific entrypoints для выполнения canonical workflow.
#### Scenario: Участник начинает работу из любого active entrypoint
- **WHEN** участник открывает root или repository-local active instructions
- **THEN** он получает согласованный canonical lifecycle, применимые локальные
boundaries и ссылки на общую authority без противоречащей копии shared policy
#### Scenario: Необязательный agent runtime отсутствует
- **WHEN** участник выполняет workflow без vendor-specific commands, skills или
instruction tree
- **THEN** все canonical planning, implementation, validation и finalization
outcomes остаются достижимыми без ослабления safeguards
@@ -1,76 +0,0 @@
# audit-remediation-workflow Specification
## Purpose
Определяет устойчивый способ продолжать и подтверждать устранение findings технического аудита WealthLedger между сессиями и независимыми репозиториями.
## Requirements
### Requirement: Реестр remediation отделён от снимка аудита
Система SHALL создавать отдельный неизменяемый versioned audit snapshot в `docs/audits` для каждого завершённого аудита и SHALL поддерживать один актуальный durable remediation registry в `docs/development`, охватывающий findings всех audit snapshots. Для завершённого multi-source audit система SHALL сохранять каждый source file byte-for-byte и объединять их одним snapshot index. Каждый snapshot SHALL оставаться source of claims своего аудита и MUST NOT редактироваться для отражения последующей remediation. Внешняя модельная оценка MUST быть обозначена как неавторитетный источник claims и MUST NOT подменять requirements, governance или проверенное состояние проекта. Registry SHALL хранить текущее состояние, связанный canonical change, следующее действие и completion evidence каждой записи, MUST ссылаться на однозначный Audit ID и каждый относящийся source snapshot и MUST NOT дублировать полный текст finding, если он уже содержится в snapshot.
#### Scenario: Агент открывает работу по аудиту в новой сессии
- **WHEN** агент получает задачу устранить или продолжить finding из любого завершённого аудита
- **THEN** он находит в едином registry исходный snapshot и Audit ID, текущий статус, связанный OpenSpec change, evidence и следующее действие без повторной интерпретации всех аудитов
#### Scenario: Проводится повторный аудит
- **WHEN** новый аудит завершён после предыдущего audit snapshot
- **THEN** система добавляет новый immutable snapshot и обновляет тот же live registry, не переписывая предыдущий snapshot и не создавая конкурирующий registry
#### Scenario: Обновляется прогресс remediation
- **WHEN** работа по finding меняет статус
- **THEN** versioned audit snapshots не редактируются, а изменение фиксируется только в live registry с датой и необходимым evidence
#### Scenario: Один аудит содержит несколько внешних оценок
- **WHEN** завершённая assessment использовала несколько независимых source reports
- **THEN** snapshot index связывает byte-for-byte сохранённые reports в один audit и явно отделяет их claims от project authority
### Requirement: Registry задаёт однозначный статус и следующий change
Каждая audit-derived запись SHALL иметь ровно один статус из `planned`, `active`, `done`, `deferred` или `accepted`. `planned` MUST означать согласованную работу, которую можно начать без внешнего review trigger; запись, ожидающая версии зависимости, внешнего события или отдельного решения владельца, MUST иметь статус `deferred` с условием пересмотра. Registry MUST содержать не более одного `active` remediation change и ровно одну следующую рекомендуемую задачу, пока существуют findings со статусом `planned`.
#### Scenario: В рабочей области нет активного remediation change
- **WHEN** агент открывает registry и все незакрытые немедленно исполнимые findings имеют статус `planned`
- **THEN** он может выбрать ровно одну строку, отмеченную как следующая рекомендуемая задача, и создать связанный canonical OpenSpec change
#### Scenario: В рабочей области уже есть активный remediation change
- **WHEN** registry содержит запись со статусом `active`
- **THEN** агент продолжает связанный change и не создаёт второй remediation change без явного решения владельца
#### Scenario: Finding зависит от будущего trigger
- **WHEN** remediation нельзя обоснованно начать до указанной версии зависимости, внешнего события или решения владельца
- **THEN** registry хранит статус `deferred`, rationale и проверяемое условие пересмотра вместо представления finding как готового `planned` work
### Requirement: Terminal status подтверждён проверяемым evidence
Registry SHALL NOT представлять finding как `done` без даты, archive path связанного OpenSpec change, commit identifiers для каждого затронутого Git-репозитория и точных non-mutating verification commands с результатами. Архивированный audit-derived change MUST иметь все tasks завершёнными до archive; completion evidence после archive MUST быть отдельной записью registry или history, а не незакрытым task в archived change. Статусы `deferred` и `accepted` MUST содержать дату, rationale и условие пересмотра либо ссылку на явное решение владельца.
#### Scenario: Change готовится к завершению
- **WHEN** implementer хочет поставить связанный finding в `done`
- **THEN** все tasks change и требуемая OpenSpec verification завершены до archive, а registry содержит полный набор post-archive evidence
#### Scenario: Риск сознательно не исправляется сейчас
- **WHEN** владелец переносит finding или принимает риск
- **THEN** registry сохраняет причину решения и условия, при которых оно должно быть пересмотрено
### Requirement: Каждое audit claim получает доказательный disposition
Каждое уникальное claim завершённого multi-source audit MUST входить ровно в один нормализованный Audit ID и иметь triage disposition `confirmed`, `already-covered`, `rejected` или `deferred`. Нормализованная запись MUST ссылаться на каждый source report, содержащий claim. `confirmed` MUST связываться с remediation status и change либо terminal evidence; `already-covered` MUST приводить проверяемое существующее evidence; `rejected` MUST содержать конкретное rationale; `deferred` MUST содержать review trigger. Triage disposition MUST оставаться отдельным от remediation status `planned`, `active`, `done`, `deferred` или `accepted`, чтобы внешняя рекомендация не становилась принятой policy без проверки.
#### Scenario: Несколько моделей повторяют одно замечание
- **WHEN** два или более source reports описывают один и тот же underlying gap
- **THEN** registry содержит один нормализованный Audit ID со ссылками на все источники и одним доказательным disposition
#### Scenario: Рекомендация уже реализована
- **WHEN** проверяемое состояние проекта полностью покрывает external claim
- **THEN** disposition `already-covered` ссылается на существующий requirement, guardrail или completion evidence без создания фиктивной remediation work
#### Scenario: Внешняя рекомендация противоречит authority
- **WHEN** source report предлагает неподдерживаемый либо ухудшающий действующий invariant подход
- **THEN** disposition `rejected` фиксирует конкретное противоречие и не превращает recommendation в requirement
@@ -0,0 +1,14 @@
# http-api-foundation Specification
## Purpose
Определяет общий безопасный фундамент backend HTTP API, на котором строятся доменные endpoints.
## Requirements
### Requirement: Backend предоставляет безопасный API-фундамент
Backend SHALL публиковать HTTP API с префиксом `/api/v1`, принимать разрешенные frontend origins только из конфигурации и возвращать клиенту безопасные ошибки без секретов или внутренних деталей.
#### Scenario: Запрос приходит с неразрешенного origin
- **WHEN** браузер отправляет cross-origin запрос не с настроенного frontend origin
- **THEN** backend не разрешает браузеру доступ к ответу через CORS
@@ -7,7 +7,9 @@
### Requirement: Realized income analytics is account-scoped and period-bound
Система MUST предоставлять аутентифицированному User аналитику фактически полученного дохода только для одного открытого поддерживаемого Brokerage Account, доступного через текущий T-Bank connection User. Применённый период MUST задаваться включительными календарными датами `from` и `to` в Europe/Moscow, MUST NOT начинаться после своего окончания, MUST NOT содержать будущую дату. Система MUST NOT ограничивать максимальную длительность периода. При отсутствии применённого периода система MUST использовать интервал от первого дня текущего месяца до сегодняшнего дня включительно.
Система MUST предоставлять аутентифицированному User аналитику фактически полученного дохода выбранного открытого поддерживаемого Brokerage Account либо существующего режима «Все счета» (`all`) из счетов, доступных через текущий T-Bank connection User. В режиме `all` система MUST проверять доступ каждого входящего счёта и подтверждать полноту каждого исходного периода по всем входящим счетам; отсутствующий вклад счёта MUST NOT заменяться нулём. Применённый период MUST задаваться включительными календарными датами `from` и `to` в Europe/Moscow, MUST NOT начинаться после своего окончания, MUST NOT содержать будущую дату. Система MUST NOT ограничивать максимальную длительность периода. При отсутствии применённого периода система MUST использовать интервал от первого дня текущего месяца до сегодняшнего дня включительно.
Чтение MUST использовать сохранённые операции текущего подключения и локальное подтверждение доступа без синхронного запроса T-Bank. Ответ MUST иметь envelope `{ data, meta }` по `tbank-data-sync`. Полнота MUST определяться для выбранного периода и каждого необходимого периода сравнения отдельно. Запрос MUST приоритетно загружать их пропуски до фоновой догрузки остальной истории; известные правила календарных дат MUST сохраняться.
#### Scenario: Аналитика за период по умолчанию
@@ -29,6 +31,14 @@
- **WHEN** User запрашивает аналитику Brokerage Account, который не является открытым поддерживаемым счётом, доступным через его текущий T-Bank connection
- **THEN** система возвращает существующий ответ о недоступности счёта и не раскрывает его доход или операции
#### Scenario: Общий доход требует полноты всех счетов
- **WHEN** User запрашивает режим `all` и выбранный период одного доступного счёта ещё не загружен полностью
- **THEN** система не возвращает окончательные общие суммы, сохраняет раздельное состояние покрытия счетов и запрашивает недостающую разрешённую историю
#### Scenario: Открыт ещё не загруженный период
- **WHEN** запрошенный допустимый период ещё не имеет сохранённых операций или подтверждённого полного пустого покрытия
- **THEN** система отвечает `200` со статусом первичной загрузки или частичного покрытия, запрашивает нужные интервалы и не выдаёт нулевой окончательный доход
### Requirement: Realized income uses exact executed coupon and dividend payments
Система MUST рассчитывать фактически полученный доход только по исполненным Ledger Entries с точным upstream-типом coupon или dividend. Для каждой исходной валюты система MUST отдельно возвращать общий итог, купонный subtotal и дивидендный subtotal как точные суммы исходных broker-reported payments. Знак каждой выплаты MUST сохраняться, поэтому отрицательная корректировка MUST уменьшать соответствующий итог и оставаться доступной в составе результата. Dividend transfer, погашение principal, offer, trade, transfer, fee, tax, overnight income, иной тип операции, нереализованный результат и изменение рыночной цены MUST NOT входить в фактически полученный доход. Система MUST NOT выполнять валютную конвертацию или формировать общий итог по разным валютам.
@@ -103,8 +113,9 @@ totals, comparisons, buckets или
breakdown как полный результат. При временной недоступности клиент MUST
сохранять предыдущий полный результат той же выборки либо показывать
нейтральную недоступность соответствующего расчёта без технических сообщений
и ручного повтора. Восстановление SHALL выполняться автоматически по
`frontend-data-resilience`.
и ручного повтора, пока автоматические повторы не исчерпаны; после их исчерпания
применяются предупреждение и «Повторить» по `frontend-data-resilience`.
Восстановление SHALL выполняться автоматически по `frontend-data-resilience`.
Доступные независимые части MUST оставаться видимыми; неполное сравнение MUST
обозначаться как недоступное без нулевой базы и окончательной разницы. Успешный
@@ -138,7 +149,7 @@ summary «Получено купонами и дивидендами» за п
Пустой или неуспешный расчёт MUST оставаться локальным состоянием блока и
MUST NOT заменять успешно отображённый Portfolio или другие блоки обзора.
Временная ошибка обновления MUST сохранять доступную сводку той же выборки без
предупреждения и ручного повтора. При отсутствии результата SHALL показываться
предупреждения и ручного повтора, пока автоматические повторы не исчерпаны. При отсутствии результата SHALL показываться
нейтральная недоступность после первичного ожидания с автоматическим восстановлением.
#### Scenario: Обзор показывает доход текущего месяца
@@ -155,11 +166,11 @@ MUST NOT заменять успешно отображённый Portfolio ил
### Requirement: Income payments remain traceable to exact Ledger Entries
В разделе «Выплаты» страница MUST показывать единый список карточек точных исполненных Ledger Entries применённого периода в исходном серверном порядке entries. Каждая выплата MUST иметь собственную сразу видимую карточку с описанием, датой исполнения, доступной identity Instrument и signed Money в исходной валюте. Купоны и дивиденды MUST использовать то же представление выплаты, что и на экранах журнала, прогноза и графиков инструментов, при этом карточка MUST обозначать эту сумму именно как фактически исполненную, а не прогнозную. Страница MUST NOT добавлять внешние группировочные карточки или заголовки по Instrument либо валюте, subtotals инструмента или количество выплат инструмента. Список MUST сохранять отрицательные корректировки и повторяющиеся operation IDs как отдельные записи и MUST NOT включать записи вне аналитического итога. Backend MUST обеспечивать согласованность entries, instrumentKey, валюты и receipt count при формировании ответа из одного набора записей. Frontend SHALL доверять типизированному contract без второго runtime consistency validator и MUST NOT пересчитывать денежные итоги.
В разделе «Выплаты» страница MUST показывать единый список карточек точных исполненных Ledger Entries применённого периода в исходном серверном порядке entries. Каждая выплата MUST иметь собственную сразу видимую карточку с описанием, датой исполнения, доступной identity Instrument и signed Money в исходной валюте. Купоны и дивиденды MUST использовать то же представление выплаты, что и на экранах журнала, прогноза и графиков инструментов, при этом карточка MUST обозначать эту сумму именно как фактически исполненную, а не прогнозную. В режиме одного Brokerage Account страница MUST NOT добавлять внешние группировочные карточки или заголовки по Instrument либо валюте, subtotals инструмента или количество выплат инструмента. В режиме «Все счета» записи раздела «Выплаты» MUST группироваться по тем же правилам ключа, состава, порядка и итога, что и записи журнала по Requirements «All-accounts ledger groups same-day entries of one instrument», «All-accounts ledger group shows each entry and an exact total» и «All-accounts ledger grouping preserves order and limits» capability `brokerage-account-ledger`. Каждая запись при этом MUST оставаться отдельной сразу видимой карточкой внутри группы, а итог группы MUST NOT показываться, пока полнота периода не подтверждена; заголовки валютных групп и receipt count MUST NOT добавляться. Список MUST сохранять отрицательные корректировки и повторяющиеся operation IDs как отдельные записи и MUST NOT включать записи вне аналитического итога. Backend MUST обеспечивать согласованность entries, instrumentKey, валюты и receipt count при формировании ответа из одного набора записей. Frontend SHALL доверять типизированному contract без второго runtime consistency validator и MUST NOT пересчитывать аналитические денежные итоги. Единственное исключение — итог визуальной группы режима «Все счета»: это точная сумма показанных записей группы по каждой валюте, и он MUST NOT подменять аналитические итоги.
#### Scenario: Каждая выплата показана отдельной карточкой
- **WHEN** выбранный период содержит несколько включённых Ledger Entries одного или разных Instrument
- **WHEN** в режиме одного Brokerage Account выбранный период содержит несколько включённых Ledger Entries одного или разных Instrument
- **THEN** раздел «Выплаты» сразу показывает отдельную карточку каждой записи в исходном порядке без внешних группировочных блоков, раскрытия, итогов инструмента и receipt count на UI
#### Scenario: Список воспроизводит точный состав дохода
@@ -174,9 +185,14 @@ MUST NOT заменять успешно отображённый Portfolio ил
#### Scenario: Валюта видна в сумме каждой выплаты
- **WHEN** подходящие выплаты одного Instrument представлены в нескольких исходных валютах
- **WHEN** подходящие в режиме одного Brokerage Account выплаты одного Instrument представлены в нескольких исходных валютах
- **THEN** каждая карточка показывает свою Money с исходной валютой, без отдельного заголовка валютной группы или объединённого мультивалютного итога
#### Scenario: Выплаты одной бумаги на двух счетах в режиме «Все счета»
- **WHEN** в режиме «Все счета» за один день получены купон по облигации на брокерском счёте +200 RUB и купон по той же облигации на ИИС +100 RUB
- **THEN** раздел «Выплаты» показывает их одной группой с итогом +300 RUB, внутри которой видны отдельные карточки каждого счёта, а аналитические итоги страницы не меняются
### Requirement: Analytics summary remains readable per currency
Страница MUST обозначать фактический результат как «Получено купонами и дивидендами» и для каждой исходной валюты показывать total, coupon subtotal и dividend subtotal выбранного периода. Она MUST объяснять, что tax operations не включены, не называя фактический результат gross, net, суммой до налогов, суммой после налогов или налоговым расчётом. Значения MUST оставаться доступными как читаемый текст, не только как графика, цвет или положение. Страница MUST NOT показывать блок «Динамика», его график, переключатель валюты графика или текстовый список временных buckets независимо от длительности выбранного периода. Страница MUST NOT показывать блоки «Предыдущий период» и «Годом ранее» или сообщения о недоступности сравнений.
@@ -7,16 +7,30 @@
## Requirements
### Requirement: User can retrieve an account ledger
Система MUST предоставлять аутентифицированный журнал только для чтения одного Brokerage Account, принадлежащего текущему User. Журнал MUST содержать только исполненные операции T-Bank и при отсутствии периода по умолчанию охватывать интервал от начала текущего календарного года до момента запроса.
Система MUST предоставлять аутентифицированный журнал только для чтения выбранного Brokerage Account либо существующего режима «Все счета» (`all`) в границах текущего User и его T-Bank connection. В режиме `all` доступ MUST проверяться для каждого входящего счёта, операции MUST сохранять принадлежность исходному счёту, а полнота периода MUST учитывать покрытие каждого входящего счёта. Журнал MUST содержать только исполненные операции T-Bank и при отсутствии периода по умолчанию охватывать интервал от начала текущего календарного года в UTC до момента запроса.
Система MUST читать сохранённые операции и локальное подтверждение доступа текущего подключения без синхронного обращения к T-Bank, включая запрос списка счетов. Ответ MUST содержать envelope `{ data, meta }` по `tbank-data-sync`, отражающий полноту запрошенного периода независимо от текущей страницы. Отсутствующие данные MUST возвращаться с `data: null` и состоянием загрузки, а частично сохранённый период MUST обозначаться как `partial`. Только подтверждённо полный пустой период MUST обозначать отсутствие операций.
#### Scenario: Получение журнала с периодом по умолчанию
- **WHEN** аутентифицированный User запрашивает журнал принадлежащего ему Brokerage Account без периода
- **THEN** система возвращает его исполненные операции от начала текущего календарного года до момента запроса
- **THEN** система возвращает сохранённые исполненные операции в `data` и полноту периода в `meta` за период от начала текущего календарного года в UTC до момента запроса, запрашивая отсутствующие интервалы в фоне
#### Scenario: Отклонение недоступного счёта
- **WHEN** аутентифицированный User запрашивает журнал счёта, который не является открытым поддерживаемым счётом, доступным через его T-Bank connection
- **WHEN** аутентифицированный User запрашивает журнал счёта, для которого локальные данные текущего подключения подтверждают отсутствие доступа либо неподдерживаемое или закрытое состояние
- **THEN** система возвращает существующий ответ о недоступности счёта и не раскрывает операции
#### Scenario: Общий журнал сохраняет принадлежность записей
- **WHEN** аутентифицированный User запрашивает журнал в режиме `all`
- **THEN** система возвращает исполненные операции доступных ему счетов, и каждая запись указывает свой исходный счёт
#### Scenario: Общий журнал содержит неполный счёт
- **WHEN** User запрашивает режим `all` и у одного из разрешённых счетов есть незагруженный интервал периода
- **THEN** система возвращает только разрешённые сохранённые записи с принадлежностью счёту и обозначает общий период неполным, не подменяя отсутствующий вклад пустым счётом
#### Scenario: Пустая загруженная часть не доказывает отсутствие операций
- **WHEN** в сохранённой части периода нет записей, но покрытие периода не завершено
- **THEN** ответ обозначает неполноту и запрошенную загрузку, а интерфейс не сообщает, что операций за период нет
### Requirement: User can browse a filtered ledger
Система MUST позволять User ограничивать журнал включительным периодом дат и любым подмножеством категорий trade, income, tax, fee, transfer, other. Система MUST разбивать упорядоченный результат на страницы с непрозрачным cursor и возвращать признак доступности следующей страницы.
@@ -50,12 +64,13 @@
### Requirement: Account overview includes recent ledger events
Обзор счёта MUST показывать пять последних исполненных записей журнала этого
счёта и действие перехода к его полному журналу. Каждая запись MUST иметь ту же
счёта, а в режиме «Все счета» — пять последних записей всех входящих счетов, и действие перехода к его полному журналу. Каждая запись MUST иметь ту же
визуальную идентичность инструмента и семантику денежного движения, что и запись
полного журнала. Неуспешный или пустой запрос MUST оставаться локальным состоянием
полного журнала; в группе режима «Все счета» визуальная идентичность, дата и тип
показываются в заголовке группы. Неуспешный или пустой запрос MUST оставаться локальным состоянием
блока и MUST NOT заменять успешно отображённый Portfolio. При временном сбое
обновления блок MUST сохранять доступные записи без технического предупреждения
или ручного повтора. При отсутствии результата он SHALL показывать первичное
или ручного повтора, пока автоматические повторы не исчерпаны. При отсутствии результата он SHALL показывать первичное
ожидание либо нейтральную недоступность, автоматически восстанавливая чтение.
Неподтверждённая пустота MUST NOT представляться отсутствием операций.
@@ -73,7 +88,7 @@
### Requirement: Full ledger presents compact rows
Страница полного журнала MUST показывать для каждой записи дату, тип операции, инструмент и денежное изменение в исходной валюте в компактной структурированной строке. При доступной presentation metadata инструмента строка MUST показывать его визуальную идентичность; при её отсутствии MUST использовать понятный текстовый fallback. Для купонных и дивидендных операций на странице полного журнала и в личной истории инструмента MUST использоваться то же представление выплаты, что в доходе, прогнозе и графиках инструментов, с обозначением исполненного движения. Для каждой записи денежное изменение в исходной валюте MUST сопровождаться подписью конкретного типа события и знаком: для купонной операции — «Купон», для дивидендной — «Дивиденд», для остальных записей — подпись их категории («Сделка», «Налог», «Комиссия», «Перевод», «Прочее»). Строка MUST NOT повторять подпись типа события во вторичной метадате записи. Знак «+» / «−» и семантический цвет MUST оставаться носителями направления и MUST NOT заменяться только текстом. Страница MUST обозначать записи без движения денег нейтрально и MUST NOT показывать цену или количество.
Страница полного журнала MUST показывать для каждой записи дату, тип операции, инструмент и денежное изменение в исходной валюте в компактной структурированной строке. При доступной presentation metadata инструмента строка MUST показывать его визуальную идентичность; при её отсутствии MUST использовать понятный текстовый fallback. Для купонных и дивидендных операций на странице полного журнала и в личной истории инструмента MUST использоваться то же представление выплаты, что в доходе, прогнозе и графиках инструментов, с обозначением исполненного движения. Для каждой записи денежное изменение в исходной валюте MUST сопровождаться подписью конкретного типа события и знаком: для купонной операции — «Купон», для дивидендной — «Дивиденд», для остальных записей — подпись их категории («Сделка», «Налог», «Комиссия», «Перевод», «Прочее»). Строка MUST NOT повторять подпись типа события во вторичной метадате записи. Знак «+» / «−» и семантический цвет MUST оставаться носителями направления и MUST NOT заменяться только текстом. Страница MUST обозначать записи без движения денег нейтрально и MUST NOT показывать цену или количество. В группе режима «Все счета» дата, тип операции, инструмент и его визуальная идентичность MUST показываться один раз в заголовке группы, а строка каждой записи группы MUST показывать её счёт и денежное изменение с подписью типа и знаком.
#### Scenario: Отображение записи только с инструментом
@@ -110,6 +125,11 @@
- **WHEN** полный журнал отображает запись без доступной presentation metadata инструмента
- **THEN** строка показывает понятный текстовый fallback без подмены или сокрытия доступных данных инструмента
#### Scenario: Запись внутри группы режима «Все счета»
- **WHEN** полный журнал в режиме «Все счета» показывает запись в составе группы
- **THEN** её дата, тип и инструмент читаются из заголовка группы, а строка записи показывает счёт и денежное изменение со знаком и подписью типа
### Requirement: Кэш страниц журнала ограничивает локальные ресурсы
Система SHALL кэшировать не более 100 недавно использованных страниц Account Ledger в каждом backend process не более чем на 30 секунд. Cache key MUST включать User, Brokerage Account, нормализованные период и categories, opaque cursor и limit; порядок или дублирование categories MUST NOT создавать отдельную cache identity, а отсутствующий конец периода MUST использовать текущий 30-секундный bucket. Просроченная страница MUST NOT возвращаться, а при переполнении cache MUST удалять наименее недавно использованную страницу.
@@ -165,15 +185,89 @@
обновления и восстанавливать чтение автоматически по `frontend-data-resilience`.
Период, категории и выбранная страница MUST сохраняться; результат другой
выборки MUST NOT подменять текущую. Страница MUST NOT показывать время получения,
технические статусы или действия ручного повтора. Даты операций, фильтры,
технические статусы или действия ручного повтора до исчерпания автоматических
повторов (далее по `frontend-data-resilience`). Даты операций, фильтры,
действия их применения и переходы по страницам MUST оставаться доступными.
При отсутствии результата SHALL применяться первичное ожидание и нейтральная
недоступность, а не сообщение об отсутствии операций до подтверждения полноты.
#### Scenario: Обновление журнала временно не удалось
- **WHEN** для применённого периода и страницы уже есть записи, а обновление завершается временной ошибкой
- **THEN** записи и параметры просмотра сохраняются без предупреждения и кнопки повтора до автоматического восстановления
- **THEN** записи и параметры просмотра сохраняются без предупреждения и кнопки повтора до автоматического восстановления либо до исчерпания автоматических повторов
#### Scenario: Следующая страница ещё не получена
- **WHEN** пользователь перешёл к следующей странице, но её загрузка временно не удалась
- **THEN** область новой страницы показывает ожидание или нейтральную недоступность, не выдаёт прежние записи за новую страницу и восстанавливает чтение автоматически
### Requirement: All-accounts ledger groups same-day entries of one instrument
В режиме «Все счета» полный журнал и последние записи обзора MUST объединять в визуальную группу две и более показанных записи с одинаковыми днём (как на экране), identity Instrument и точным upstream-типом операции, даже если они на одном счёте. Записи без Instrument или без пары MUST показываться как раньше. В режиме одного Brokerage Account группировка MUST NOT применяться.
#### Scenario: Купоны одной облигации на двух счетах
- **WHEN** в режиме «Все счета» журнал показывает за один день купон по облигации на брокерском счёте +200 RUB и купон по той же облигации на ИИС +100 RUB
- **THEN** они показаны одной группой с облигацией, типом «Купон» и итогом +300 RUB, а под заголовком видны строки «брокерский счёт +200 RUB» и «ИИС +100 RUB»
#### Scenario: Две операции одного типа на одном счёте
- **WHEN** в режиме «Все счета» журнал показывает за один день две покупки одной бумаги на одном счёте
- **THEN** они показаны одной группой, где каждая покупка остаётся отдельной строкой со своим денежным изменением
#### Scenario: Разные типы по одной бумаге не объединяются
- **WHEN** в режиме «Все счета» за один день по одной облигации есть купон и налог с этого купона
- **THEN** купоны и налоги показаны раздельно, и ни одна группа не содержит записи разных типов
#### Scenario: Покупка и продажа не объединяются
- **WHEN** в режиме «Все счета» за один день показаны покупка и продажа одной бумаги
- **THEN** они не входят в одну группу
#### Scenario: Разные дни не объединяются
- **WHEN** в режиме «Все счета» купоны по одной облигации на двух счетах датированы на экране разными днями
- **THEN** они показаны отдельными записями без группы
#### Scenario: Запись без Instrument
- **WHEN** в режиме «Все счета» за один день показаны две записи одного типа без identity Instrument
- **THEN** они показаны отдельными записями так же, как без группировки
#### Scenario: Режим одного счёта не меняется
- **WHEN** User открывает журнал или обзор одного Brokerage Account, где есть два купона по одной облигации за один день
- **THEN** записи показаны отдельными строками без группы, как до изменения
### Requirement: All-accounts ledger group shows each entry and an exact total
Заголовок группы журнала MUST показывать дату, Instrument с визуальной идентичностью, подпись типа операции, как в строке журнала, и итог. Под ним MUST всегда быть видна, без раскрытия, отдельная строка каждой записи со счётом и денежным изменением. Итог SHALL быть точной суммой со знаком по каждой исходной валюте без конвертации и округления. Итог MUST NOT показываться, если у записи группы нет денежного изменения (включая нулевое) или полнота периода не подтверждена.
#### Scenario: Итог по нескольким валютам
- **WHEN** группа содержит записи с денежными изменениями в RUB и в USD
- **THEN** заголовок показывает отдельный точный итог в RUB и отдельный в USD, без конвертации
#### Scenario: Итог со знаком
- **WHEN** группа содержит поступление +150.10 RUB и отрицательную корректировку −50.05 RUB
- **THEN** итог группы равен +100.05 RUB, а обе записи видны отдельными строками со своими знаками
#### Scenario: Нулевой итог
- **WHEN** группа содержит +150.10 RUB и −150.10 RUB
- **THEN** заголовок показывает итог 0 RUB без знака направления, а обе записи видны со своими знаками
#### Scenario: Группа с записью без денежного изменения
- **WHEN** у одной из записей группы нет денежного изменения
- **THEN** заголовок группы не показывает итог, а записи остаются видны отдельными строками
#### Scenario: Запись с нулевой суммой скрывает итог
- **WHEN** группа содержит запись +100 RUB и запись с денежным изменением 0 RUB
- **THEN** заголовок группы не показывает итог, а запись с нулём обозначена как запись без движения денег
#### Scenario: Итог скрыт при неполном периоде
- **WHEN** журнал в режиме «Все счета» показывает группу, а период обозначен как `partial`
- **THEN** заголовок группы не показывает итог, а записи группы остаются видны со своими суммами
### Requirement: All-accounts ledger grouping preserves order and limits
Группа журнала MUST занимать позицию своей первой записи в серверном порядке; записи внутри группы и остальные строки MUST сохранять относительный серверный порядок. Группировка MUST NOT менять состав, фильтрацию, пагинацию или лимит обзора, который считается по записям. Группа собирается только из показанных записей; это осознанное исключение: её итог относится только к ним и не сопровождается пометкой о неполноте.
#### Scenario: Позиция группы в списке
- **WHEN** серверный порядок записей A, B, C, где A и C образуют группу, а B не входит ни в одну группу
- **THEN** список показывает группу (A, C) на месте A, затем B
#### Scenario: Группа на границе страницы
- **WHEN** записи одной группы попадают на текущую и на ещё не показанную страницу журнала при подтверждённой полноте периода
- **THEN** группа на текущей странице содержит только её показанные записи, итог равен их сумме, и заголовок не содержит пометки о неполноте
#### Scenario: Группировка в последних записях обзора
- **WHEN** в режиме «Все счета» среди пяти последних записей есть две записи с одинаковым ключом
- **THEN** блок обзора показывает те же пять записей, что и без группировки, две из них одной группой
@@ -80,22 +80,31 @@ Instrument в Future Cash Event MUST иметь тот же доменный с
- **THEN** система не добавляет для него синтетическую строку в результат
#### Scenario: MOEX отвечает без запрошенного блока
- **WHEN** MOEX отвечает успешно, но не содержит запрошенного блока данных
- **THEN** система трактует ответ как отсутствие данных по инструменту и не завершает обновление ошибкой
- **WHEN** MOEX отвечает успешно, но не содержит запрошенного блока данных, либо блок или его постраничный курсор некорректны
- **THEN** система трактует ответ как неуспех источника: не публикует и не заменяет календарь инструмента, сохраняет последний полный календарь и прогноз, а `meta` отдельно обозначает неуспех последней попытки
#### Scenario: MOEX подтверждает отсутствие данных
- **WHEN** MOEX отвечает успешно и содержит запрошенный блок с корректными колонками и без строк, либо сообщает, что инструмент не найден
- **THEN** система трактует ответ как отсутствие данных по инструменту, не создаёт для него строк и не завершает обновление ошибкой
### Requirement: Account page presents an upcoming events list
Страница Brokerage Account MUST предоставлять действие перехода на отдельную
страницу «Будущие события». Эта страница MUST показывать все полученные события
компактным списком без фильтров, пагинации и суммарного итога, с датой, типом,
инструментом и прогнозной суммой в исходной валюте, если она известна. Страница
компактным списком за выбранный период без пагинации и суммарного итога списка, с датой, типом,
инструментом и прогнозной суммой в исходной валюте, если она известна. Прогнозный итог
визуальной группы режима «Все счета» не является суммарным итогом списка. Страница
MUST объяснять, что прогноз основан на позиции счёта, указан до налогов и может
быть неполным из-за доступности данных источника. Это предметное ограничение
прогноза MUST NOT превращаться в отчёт о технической синхронизации.
Страница MUST показывать исходные `calculatedAt` и включительное окно сохранённого прогноза,
в том числе когда выбранный период отличается от окна прогноза.
При временном сбое обновления страница MUST сохранять ранее доступный результат
той же выборки с его исходными `calculatedAt` и окном, без индикации
собственного чтения и ручного повтора. Состояние источника из `meta`, включая
собственного чтения и ручного повтора до исчерпания автоматических повторов
(затем применяются предупреждение и «Повторить» по `frontend-data-resilience`). Состояние источника из `meta`, включая
неуспех последней часовой попытки и неполноту, MUST представляться по
`frontend-data-resilience`. При отсутствии результата SHALL показываться
первичное ожидание либо нейтральная недоступность с автоматическим
@@ -104,15 +113,15 @@ MUST скрывать личные данные независимо от нал
#### Scenario: Просмотр ближайших событий
- **WHEN** User открывает «Будущие события» из обзора Brokerage Account
- **THEN** он видит отсортированный список событий ближайших 30 дней, где offer обозначен без суммы
- **THEN** он видит отсортированный список событий выбранного периода (по умолчанию -- ближайшие 7 дней), где offer обозначен без суммы
#### Scenario: Пустое состояние страницы
- **WHEN** запрос будущих событий успешно возвращает полный пустой список
- **THEN** страница сообщает, что на ближайшие 30 дней событий не найдено, и объясняет предметные ограничения покрытия источника
- **THEN** страница сообщает, что за выбранный период событий не найдено, и объясняет предметные ограничения покрытия источника
#### Scenario: Ошибка страницы
- **WHEN** запрос будущих событий завершается временной ошибкой
- **THEN** страница сохраняет доступный результат той же выборки либо показывает нейтральную недоступность без ручного повтора, восстанавливая чтение автоматически и не выдавая частичные данные за полный прогноз
- **THEN** страница сохраняет доступный результат той же выборки либо показывает нейтральную недоступность без ручного повтора до исчерпания автоматических повторов, восстанавливая чтение автоматически и не выдавая частичные данные за полный прогноз
#### Scenario: Прогнозный дивиденд с неизвестной суммой
- **WHEN** прогноз содержит дивиденд с известной датой реестра, но без суммы
@@ -192,3 +201,56 @@ continue to use the existing URL-backed period behavior.
a range whose `from` date is after its `to` date
- **THEN** the page applies the default seven-calendar-date range
- **AND** invalid `from` and `to` parameters are removed from the URL
### Requirement: All-accounts upcoming events group same-day events of one instrument
В режиме «Все счета» страница «Будущие события» и ближайшие события обзора MUST объединять в визуальную группу два и более показанных Future Cash Event с одинаковыми датой и видом даты (как на экране), identity Instrument и типом события, даже если они на одном счёте. События без Instrument или без пары MUST показываться как раньше. В режиме одного Brokerage Account группировка MUST NOT применяться.
#### Scenario: Прогноз купонов одной облигации на двух счетах
- **WHEN** в режиме «Все счета» на одну дату ожидается купон по облигации на брокерском счёте 200 RUB и купон по той же облигации на ИИС 100 RUB
- **THEN** они показаны одной группой с облигацией, типом «Купон» и прогнозным итогом 300 RUB, а под заголовком видны строки «брокерский счёт 200 RUB» и «ИИС 100 RUB»
#### Scenario: Разные типы или даты не объединяются
- **WHEN** в режиме «Все счета» по одной облигации ожидаются купон и погашение на одну дату либо купоны на разные даты
- **THEN** ни одна группа не содержит события разных типов или разных дат
#### Scenario: Событие без Instrument
- **WHEN** в режиме «Все счета» на одну дату показаны два события одного типа без identity Instrument
- **THEN** они показаны отдельными строками без группы
#### Scenario: Режим одного счёта не меняется
- **WHEN** User открывает «Будущие события» одного Brokerage Account
- **THEN** события показаны отдельными строками без группы, как до изменения
### Requirement: All-accounts upcoming event group shows each event and a forecast total
Заголовок группы событий MUST показывать дату (для дивиденда — как дату реестра, если она такая), Instrument, тип и прогнозный итог. Под ним MUST всегда быть видна, без раскрытия, отдельная строка каждого события со счётом и прогнозной суммой (или пометкой о неизвестной сумме). Итог SHALL быть точной суммой со знаком по каждой валюте без конвертации и округления. Итог MUST NOT показываться, если у события группы нет суммы или полнота прогноза не подтверждена.
#### Scenario: Событие с неизвестной суммой в группе
- **WHEN** в режиме «Все счета» группа содержит дивиденд на одном счёте с известной суммой и дивиденд по той же бумаге на ту же дату на другом счёте без суммы
- **THEN** заголовок группы не показывает итог, а строка второго счёта обозначает сумму как неизвестную
#### Scenario: Оферта без сумм
- **WHEN** в режиме «Все счета» на одну дату ожидается оферта по одной облигации на двух счетах
- **THEN** они показаны одной группой без итога, и каждая строка показывает свой счёт
#### Scenario: Прогнозный итог по нескольким валютам
- **WHEN** группа содержит дивиденд 10 USD на одном счёте и дивиденд 200 RUB по той же бумаге на ту же дату на другом счёте
- **THEN** заголовок показывает отдельные прогнозные итоги 10 USD и 200 RUB без конвертации
#### Scenario: Прогнозный итог скрыт при неполном прогнозе
- **WHEN** в режиме «Все счета» прогноз обозначен неполным, и на странице есть группа событий с известными суммами
- **THEN** заголовок группы не показывает итог, а строки событий остаются видны со своими суммами
#### Scenario: Группа дивидендов по дате реестра
- **WHEN** группа содержит дивиденды, датированные датой реестра
- **THEN** заголовок обозначает дату как дату реестра
### Requirement: All-accounts upcoming event grouping preserves order and limits
Группа событий MUST занимать позицию своего первого события в порядке ответа; события внутри группы и остальные строки MUST сохранять относительный порядок ответа. Группировка MUST NOT менять состав, окно, порядок по дате или лимит обзора, который считается по событиям; группы собираются только из показанных событий.
#### Scenario: Группировка в ближайших событиях обзора
- **WHEN** в режиме «Все счета» среди пяти ближайших событий есть два события с одинаковым ключом
- **THEN** блок обзора показывает те же пять событий, что и без группировки, два из них одной группой
#### Scenario: Позиция группы событий
- **WHEN** порядок ответа содержит события A, B, C, где A и C образуют группу, а B не входит ни в одну группу
- **THEN** список показывает группу (A, C) на месте A, затем B
@@ -71,7 +71,8 @@
в течение 60 секунд. После истечения этого срока открытие или возвращение
на экран SHALL инициировать получение актуального списка; имеющийся Client
Cached Snapshot MUST оставаться видимым во время обновления и после временной
ошибки без индикации собственного чтения и действий повтора.
ошибки без индикации собственного чтения и действий повтора, пока автоматические
повторы не исчерпаны (далее по `frontend-data-resilience`).
Автоматическое восстановление SHALL соответствовать `frontend-data-resilience`.
Открытый экран MAY периодически перечитывать backend-результат, сохраняя
видимый snapshot, и MUST NOT создавать импорт T-Bank. Состояние источника из
@@ -124,7 +125,7 @@ Money-итогов в исходных валютах и обозначать н
#### Scenario: Ошибка фонового обновления не скрывает snapshot
- **WHEN** обновление устаревшего списка завершается временной ошибкой при наличии последнего успешного Client Cached Snapshot
- **THEN** клиент сохраняет snapshot видимым без предупреждения и ручного повтора и восстанавливает получение данных автоматически
- **THEN** клиент сохраняет snapshot видимым без предупреждения и ручного повтора до исчерпания автоматических повторов и восстанавливает получение данных автоматически
#### Scenario: Карточка ограничивает валютные итоги
- **WHEN** доступная сводка счёта содержит итоги в трёх или более валютах
@@ -1,153 +0,0 @@
# engineering-change-governance Specification
## Purpose
Определяет соразмерные и проверяемые правила инженерных решений AI-агентов, чтобы WealthLedger переиспользовал зрелые решения, тестировал существенные риски и локально отклонял код, нарушающий инварианты проекта.
## Requirements
### Requirement: Готовые решения оцениваются до собственной реализации
Изменение, которое вводит или существенно заменяет механизм авторизации, безопасности, хранения данных, внешней интеграции, наблюдаемости, кэширования, конкурентного доступа либо общей инфраструктуры и tooling, MUST до утверждения архитектурного решения оценить существующие поддерживаемые решения. Оценка MUST сравнивать применимые варианты по соответствию требованиям, зрелости, безопасности, эксплуатационной стоимости, ограничениям и стоимости миграции. Собственная реализация SHALL допускаться только когда подходящего готового решения нет либо его существенная несовместимость явно зафиксирована вместе с рисками такого выбора. Для изменений вне перечисленных областей отдельная оценка MUST NOT быть обязательной без конкретного риска или дорогостоящего необратимого решения.
#### Scenario: Существует совместимое зрелое решение
- **WHEN** change затрагивает одну из обязательных для оценки областей и найдено поддерживаемое решение, удовлетворяющее требованиям без неприемлемых ограничений
- **THEN** архитектурное решение использует это решение либо явно объясняет, почему его совокупная стоимость или риск выше выбранной альтернативы
#### Scenario: Подходящего решения нет
- **WHEN** рассмотренные зрелые решения не выполняют существенные требования или создают неприемлемый риск
- **THEN** собственная реализация допускается только после фиксации проверенных вариантов, конкретных разрывов, принятых рисков и условия повторного пересмотра
#### Scenario: Изменение не требует solution assessment
- **WHEN** change не относится к обязательным областям и не содержит отдельного дорогостоящего или необратимого решения
- **THEN** отсутствие формальной оценки готовых решений не блокирует SDD lifecycle и не создаёт дополнительный артефакт ради процесса
### Requirement: Тестирование определяется риском поведения
План изменения MUST требовать автоматизированные тесты для security- и privacy-sensitive поведения, денежных расчётов, целостности и миграции данных, конкурентного доступа, публичных контрактов, критических пользовательских сценариев и подтверждённых регрессий. Изменение MUST NOT требовать новый тест только из-за появления или изменения файла. Простые декларативные структуры, DTO без собственной логики, тонкие адаптеры, прямолинейная оркестрация, hygiene scripts и поведение сторонней библиотеки SHALL проверяться существующим контрактным или интеграционным gate без отдельного unit test, если они не содержат самостоятельного ветвления, преобразования, инварианта либо ранее найденной регрессии. Существующий frontend coverage gate SHALL использоваться только как anti-regression constraint, а не как основание создавать малоценные тесты. Backend MUST NOT получать общий числовой coverage threshold только ради симметрии или процессного соответствия; такой gate требует отдельного утверждённого обоснования защищаемого риска.
#### Scenario: Изменяется критический инвариант
- **WHEN** change меняет безопасность, денежный результат, целостность данных, конкурентное поведение, публичный контракт, критический journey или исправляет регрессию
- **THEN** checklist содержит тест соответствующего уровня, который наблюдаемо подтверждает новый или исправленный результат
#### Scenario: Добавлен простой служебный скрипт
- **WHEN** скрипт только последовательно запускает существующие проверяемые команды, не содержит самостоятельной бизнес-логики, ветвления или преобразования данных
- **THEN** отдельный unit test для скрипта не является обязательным, а корректность результата подтверждается выполнением самого локального gate
#### Scenario: Coverage можно повысить только малоценным тестом
- **WHEN** новый тест повторял бы декларативную структуру или внутреннее поведение зависимости и не защищал бы самостоятельный риск проекта
- **THEN** агент не создаёт такой тест только ради процента coverage и сохраняет существующий anti-regression threshold
#### Scenario: Backend не имеет числового coverage threshold
- **WHEN** обязательные risk-based тесты backend проходят, а отдельный измеримый риск для общего coverage threshold не утверждён
- **THEN** отсутствие глобального числового threshold не считается пробелом и не блокирует change
### Requirement: Enforceable Engineering Invariant имеет локальную проверку
Каждое правило, обозначенное как обязательный Enforceable Engineering Invariant, MUST иметь детерминированную non-mutating локальную проверку и MUST блокировать завершение работы при нарушении. Такие инварианты SHALL охватывать применимые правила стиля проекта, статическую корректность, архитектурные границы, публичные контракты, безопасность и критические доменные ограничения. Рекомендация, которую невозможно надёжно проверить локально, MUST быть обозначена как guidance и MUST NOT выдаваться за hard guardrail. Проверка самого hard guardrail MUST иметь evidence, что она отклоняет хотя бы одно контролируемое репрезентативное нарушение; это evidence MUST NOT превращаться в требование unit-тестировать каждый вспомогательный скрипт.
#### Scenario: Агент пишет код не в стиле проекта
- **WHEN** изменение нарушает проверяемое правило форматирования, lint, типов, импортных границ или публичного контракта проекта
- **THEN** обязательный локальный gate завершается с ошибкой и работа не считается завершённой
#### Scenario: Правило является только рекомендацией
- **WHEN** команда не может определить детерминированную локальную проверку сформулированного предпочтения
- **THEN** правило документируется как guidance, а агент не использует его как самостоятельную причину объявить корректную реализацию невалидной
#### Scenario: Добавляется новый hard guardrail
- **WHEN** change объявляет новое правило блокирующим
- **THEN** completion evidence подтверждает как успешный compliant case, так и обнаружение контролируемого representative violation
### Requirement: Труднообратимое изменение имеет rollback contract
Design MUST определять rollback trigger, rollback actions, data safety и rollback verification, когда change затрагивает auth или security behavior, data integrity или migration, public contract либо другое существенно труднообратимое решение. Rollback contract MUST учитывать данные и совместимость, созданные после поставки change, и MUST быть выполним без нового неутверждённого архитектурного решения. Когда указанные риски неприменимы, Design SHALL фиксировать конкретную проверяемую причину `N/A` и MUST NOT создавать отдельный artifact или формальный rollback plan ради процесса.
#### Scenario: Change мигрирует данные
- **WHEN** Design изменяет хранимые данные способом, который может потребовать возврата
- **THEN** он определяет trigger, безопасные действия отката, судьбу новых данных и проверку восстановленного состояния до утверждения Tasks
#### Scenario: Change меняет публичный контракт
- **WHEN** Design вводит несовместимое изменение публичного контракта
- **THEN** rollback contract объясняет восстановление совместимости и проверяемое условие успешного отката
#### Scenario: Rollback risk отсутствует
- **WHEN** change не затрагивает auth, security, данные, публичный контракт или другое труднообратимое решение
- **THEN** Design указывает конкретную причину `N/A` без создания дополнительного rollback artifact
### Requirement: Token-efficient execution сохраняет delivery evidence
Project-owned agent guidance SHALL направлять AI-агента к совместному получению
независимых read-only evidence, targeted чтению найденных диапазонов, сужению
diff по затронутым paths и компактному представлению успешных command results.
Такая оптимизация MUST сохранять exit status, существенный observed outcome и
всё evidence, необходимое для текущего решения, canonical gate, semantic
verification или independent review. При failure, неоднозначности или
существенном расхождении workflow SHALL расширить diagnostics соразмерно
наблюдаемой проблеме. Предпочтения по batching и размеру output SHALL оставаться
guidance и MUST NOT блокировать корректную delivery без отдельного
детерминированного hard guardrail.
#### Scenario: Требуются несколько независимых read-only результатов
- **WHEN** агент до следующего смыслового решения заранее знает несколько независимых source, status или diff запросов
- **THEN** workflow направляет получить их одним evidence batch и вернуть только релевантные результаты без промежуточного model round-trip после каждого запроса
#### Scenario: Canonical gate проходит успешно
- **WHEN** обязательная verification command завершается успешно и её полный output не нужен для последующего смыслового решения
- **THEN** agent context сохраняет точную command identity, exit status и существенную итоговую summary без обязательного переноса полного verbose log
#### Scenario: Verification завершается ошибкой
- **WHEN** compact command result сообщает failure или не позволяет определить причину
- **THEN** workflow получает достаточные diagnostics для локализации ошибки и не скрывает необходимые failure details ради соблюдения output budget
#### Scenario: Independent review требует полный relevant diff
- **WHEN** reviewer должен проверить весь фактический scope реализации против утверждённых artifacts
- **THEN** token-efficient guidance не исключает relevant diff или evidence, необходимые для semantic conclusion
### Requirement: Token-efficiency оценивается на завершённых фазах вместе с качеством
Token-efficiency pilot SHALL сравнивать resource usage на успешно завершённую
SDD-фазу, включая cumulative input, количество model calls, глубину контекста и
объём tool outputs, вместе с outcome indicators: результатами обязательных
gates, количеством циклов исправления и findings independent review. Снижение
resource usage MUST NOT считаться улучшением, если сопоставимая работа чаще не
завершается, требует больше исправлений либо ухудшает verification или review
outcomes. До накопления репрезентативных сопоставимых наблюдений числовые budgets
SHALL оставаться guidance и MUST NOT становиться самостоятельным completion
blocker.
#### Scenario: Resource usage снизился без ухудшения результата
- **WHEN** pilot показывает меньше cumulative input или model calls на сопоставимую завершённую фазу, а canonical gates, fix cycles и review findings не ухудшились
- **THEN** изменение может быть представлено Engineering Owner как наблюдаемое улучшение с указанным measurement scope и ограничениями
#### Scenario: Дешёвая фаза создаёт больше rework
- **WHEN** вариант workflow использует меньше токенов, но чаще получает failed gates, дополнительные fix cycles или более серьёзные review findings
- **THEN** pilot не представляет этот вариант как token-efficiency improvement
#### Scenario: Наблюдений недостаточно для обязательного порога
- **WHEN** pilot ещё не содержит репрезентативных сопоставимых завершённых фаз или надёжной локальной проверки
- **THEN** model-call, context-depth и tool-output budgets остаются guidance и не блокируют delivery
#### Scenario: Числовой budget предлагается как hard guardrail
- **WHEN** последующий change предлагает сделать token budget обязательным completion condition
- **THEN** он должен определить защищаемый риск, детерминированную non-mutating проверку и controlled compliant/violation evidence по общему contract Enforceable Engineering Invariant
@@ -0,0 +1,14 @@
# app-shell Specification
## Purpose
Определяет общий каркас frontend-приложения, в который встраиваются доменные экраны.
## Requirements
### Requirement: Frontend предоставляет минимальный каркас приложения
Frontend SHALL предоставлять русскоязычный адаптивный каркас приложения с маршрутами `/login`, `/register`, `/accounts`, `/accounts/:accountId` и `/settings`, светлой финансовой темой и навигацией для счетов и настроек. Каркас MUST содержать состояния загрузки, ошибки и пустых данных без реализации доменных сценариев.
#### Scenario: Пользователь открывает маршрут MVP
- **WHEN** пользователь переходит на любой маршрут MVP
- **THEN** frontend отображает соответствующий каркас страницы и не показывает шаблонный Todo-интерфейс
@@ -18,7 +18,9 @@ Portfolio и других экранов чтения при фоновом об
выборки как результат новой.
Доступный результат MUST оставаться видимым без индикаторов фонового чтения
клиента, технических предупреждений об этом чтении и действий ручного повтора.
клиента, технических предупреждений об этом чтении и действий ручного повтора,
пока автоматические повторы не исчерпаны; после их исчерпания применяется
требование «Временные сбои чтения восстанавливаются автоматически».
Frontend MUST NOT подменять внутренние признаки свежести и полноты, успешное
время источника или ошибку обновления только ради отсутствия их отображения.
Client Cached Snapshot MUST оставаться отличным от server-side fallback;
@@ -43,14 +45,14 @@ MUST NOT возвращать их на экран.
#### Scenario: Resource сохраняется после неуспешной часовой попытки
- **WHEN** response содержит полный `data` и `meta`, обозначающий неуспех последней часовой попытки
- **THEN** frontend сохраняет `data` и его исходное время видимыми и отдельно обозначает неуспех попытки, не предлагая ручной повтор
- **THEN** frontend сохраняет `data` и его исходное время видимыми и отдельно обозначает неуспех попытки, не предлагая запуск повторного получения данных из источника
#### Scenario: Обновление Portfolio завершается ошибкой после успешного ответа
- **WHEN** экран Brokerage Account уже показывает Client Cached Snapshot Portfolio, а последующее обновление этого же Portfolio завершается временной ошибкой
- **THEN** frontend сохраняет Portfolio видимым без предупреждения и ручного повтора, а восстановление выполняется автоматически
- **THEN** frontend сохраняет Portfolio видимым без предупреждения и ручного повтора, а восстановление выполняется автоматически, пока автоматические повторы не исчерпаны
#### Scenario: Server-side fallback и client cached snapshot различаются
- **WHEN** backend возвращает Portfolio с `isStale` либо фоновое обновление Client Cached Snapshot завершается временной ошибкой
- **WHEN** backend возвращает Portfolio с `isStale` либо фоновое обновление Client Cached Snapshot завершается временной ошибкой, а автоматические повторы ещё не исчерпаны
- **THEN** frontend сохраняет доступные данные без технических сообщений и не подменяет один внутренний вид состояния другим
#### Scenario: Подтверждённая пустота переживает фоновую ошибку
@@ -75,7 +77,17 @@ Frontend SHALL автоматически повторять временно н
получение необходимых текущему экрану данных без ручного обновления. Повторы
одной попытки MUST иметь конечный предел и паузы, исключающие непрерывную
череду запросов. При исчерпании предела доступные данные SHALL сохраняться,
а при их отсутствии SHALL показываться локальная недоступность. Возвращение сети,
а при их отсутствии SHALL показываться локальная недоступность. На экранах
Account, Accounts, Income, Operations, Events и секций деталей инструмента при исчерпании предела
frontend SHALL дополнительно показывать неблокирующее локализованное предупреждение
о неудачном обновлении и действие «Повторить» рядом с затронутым результатом либо
локальной недоступностью. Действие MUST повторять только чтение (GET) затронутого
запроса, не запускать получение данных из источника и не менять выбранные
фильтры, период, открытые разделы и ввод; успешное чтение MUST снимать
предупреждение и MUST NOT сообщать об успехе. Предупреждение MUST NOT дублировать
состояние источника из `meta` и MUST NOT изменять `Portfolio.isStale`. На остальных
экранах чтения, включая поиск, диапазон графика, карты и настройки подключения,
предупреждение и действие повтора MUST NOT показываться. Возвращение сети,
возвращение пользователя к открытому экрану и повторное открытие экрана MUST
возобновлять получение отсутствующих или требующих обновления данных без
перезагрузки страницы. Свежий полный результат MUST NOT требовать обращения
@@ -101,6 +113,18 @@ Frontend SHALL автоматически повторять временно н
- **WHEN** временная недоступность сохраняется после конечного предела автоматических попыток
- **THEN** приложение прекращает серию повторов, сохраняет доступный результат либо локальную недоступность без бесконечной загрузки и ожидает следующего события восстановления
#### Scenario: Повторы исчерпаны при сохранённом результате
- **WHEN** на экране Account, Accounts, Income, Operations, Events либо в секции деталей инструмента автоматические повторы исчерпаны, а ранее полученный результат остаётся доступным
- **THEN** результат и его исходное состояние источника остаются видимыми, рядом показываются неблокирующее предупреждение о неудачном обновлении и действие «Повторить», а активация действия повторяет только чтение затронутого запроса и после успеха снимает предупреждение
#### Scenario: Повторы исчерпаны без результата
- **WHEN** на тех же экранах автоматические повторы исчерпаны, а результата выборки нет
- **THEN** локальная недоступность содержит действие «Повторить», повторяющее только чтение затронутого запроса, и результат одной секции деталей не блокирует восстановление остальных
#### Scenario: Поиск, график и карты остаются тихими
- **WHEN** автоматические повторы исчерпаны при чтении результата поиска, диапазона графика, карт либо настроек подключения
- **THEN** приложение не показывает предупреждение и действие повтора и сохраняет прежнее локальное поведение
#### Scenario: Сеть или пользователь вернулись
- **WHEN** после временного сбоя восстановилась сеть либо пользователь вернулся к экрану с отсутствующим или требующим обновления результатом
- **THEN** получение данных возобновляется автоматически для текущей выборки и при успехе заменяет состояние недоступности содержимым
@@ -79,7 +79,8 @@ MUST NOT заменять уже доступный контекст skeleton pl
ожидание соответствующей области. После неуспешного завершения получения
либо истечения ограниченного времени первичного ожидания область MUST переходить
в спокойное локализованное состояние недоступности без технических подробностей
и ручного повтора. Это состояние MUST NOT утверждать, что результат пустой,
и ручного повтора, пока автоматические повторы не исчерпаны (далее по
`frontend-data-resilience`). Это состояние MUST NOT утверждать, что результат пустой,
или требовать перезагрузки страницы. Продолжающееся автоматическое восстановление
MUST NOT заставлять область постоянно переключаться между недоступностью и
skeleton. После успешного получения содержимое SHALL появляться автоматически.
@@ -94,7 +95,7 @@ skeleton. После успешного получения содержимое
#### Scenario: Первое получение данных не удалось
- **WHEN** у области нет результата и первоначальная загрузка завершилась ошибкой или превысила ограниченное время ожидания
- **THEN** область показывает нейтральную недоступность без кнопки повтора, вымышленных данных и бесконечного индикатора занятости
- **THEN** область показывает нейтральную недоступность без кнопки повтора до исчерпания автоматических повторов, вымышленных данных и бесконечного индикатора занятости
#### Scenario: Восстановление продолжается после недоступности
- **WHEN** область уже показывает недоступность и выполняется очередная разрешённая попытка восстановления
@@ -30,7 +30,10 @@ Frontend SHALL сохранять локальное отображение из
Account, Portfolio и T-Bank connection. Эти состояния MUST NOT заменяться
глобальным fallback. Временная ошибка фонового чтения MUST сохранять доступный
результат без предупреждения об устаревании, технической ошибки и ручного
повтора. При отсутствии результата SHALL показываться локальная недоступность
повтора, пока автоматические повторы не исчерпаны; для Brokerage Account и
Portfolio после их исчерпания применяется `frontend-data-resilience`
(предупреждение и «Повторить»), для T-Bank connection поведение остаётся тихим.
При отсутствии результата SHALL показываться локальная недоступность
с автоматическим восстановлением. Подтверждённая необходимость входа,
восстановления подключения или выбора доступного счёта MUST сохранять понятное
объяснение и соответствующее действие, не маскируясь сохранёнными личными данными.
@@ -15,7 +15,11 @@
об успехе либо временной ошибке такого обновления. Приложение MUST NOT предоставлять
кнопки или иные действия ручного обновления и повторной загрузки, включая их
иконные варианты. Эти сведения MUST NOT переноситься в уведомления, подсказки
или объявления вспомогательных технологий. Даты операций, выплат, открытия
или объявления вспомогательных технологий. Единственное исключение — предупреждение
о неудачном обновлении и действие «Повторить» на экранах Account, Accounts, Income, Operations, Events и секций деталей инструмента после
исчерпания конечной серии автоматических повторов (по `frontend-data-resilience`);
запрет по-прежнему действует до этого момента, на остальных экранах и для иконных
вариантов обновления. Даты операций, выплат, открытия
счёта и выбранных периодов MUST сохранять свой предметный смысл.
Запрет технических действий MUST NOT удалять применение фильтров, переходы между
@@ -24,9 +28,13 @@
сохранённый ввод обычным действием формы.
#### Scenario: Фоновая загрузка завершилась ошибкой
- **WHEN** на любом из перечисленных экранов уже есть доступный результат и его фоновое обновление временно не удалось
- **WHEN** на любом из перечисленных экранов уже есть доступный результат и его фоновое обновление временно не удалось, а автоматические повторы ещё не исчерпаны
- **THEN** результат остаётся видимым без предупреждения, времени получения, индикатора, уведомления и действия ручного повтора
#### Scenario: Автоматические повторы исчерпаны на экране инвестиций
- **WHEN** на экране Account, Accounts, Income, Operations, Events либо в секции деталей инструмента автоматические повторы исчерпаны
- **THEN** экран показывает неблокирующее предупреждение и действие «Повторить» по `frontend-data-resilience`, не показывает время получения, индикатор или уведомление и не объявляет их вспомогательным технологиям как успех или прогресс
#### Scenario: Фоновая загрузка завершилась успешно
- **WHEN** приложение получает обновлённые данные без явного действия сохранения со стороны пользователя
- **THEN** содержимое обновляется без сообщения об успешной загрузке и без переноса фокуса

Some files were not shown because too many files have changed in this diff Show More