50 Commits
Author SHA1 Message Date
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
ksv741 ab24ccc32b chore(release): 0.1.5
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 11s
2026-10-18 16:24:55 +03:00
codex-bot f0478c6d6b spec: archive quiet-user-feedback and sync canonical specs (#50)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
Native archive применил 9 added и 12 modified требований без удалений; все 10 сценариев сохранены. Новый canonical spec frontend-user-feedback внесён в AI_INDEX.
2026-10-17 22:18:27 +03:00
codex-bot 5a7e319caf spec: reconcile quiet-user-feedback with source-state requirements (#50)
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 10s
Переносит change quiet-user-feedback в develop и согласует его delta-блоки с текущими canonical specs. Нормативное поведение: тихое чтение клиента и видимое состояние источника из meta.
2026-10-17 21:46:58 +03:00
codex-bot 06d08e2972 chore: adopt linear git model in specs instructions (#48)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-10-17 14:14:17 +03:00
codex-bot 1bd6181585 spec: align user-authentication password requirement with ASCII 8-72 policy (WealthLedger/specs#46) (#47)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
2026-10-16 10:47:41 +03:00
codex-bot 0a1db52baf Merge pull request 'chore: synchronize release 0.1.4 into develop (WealthLedger/main#30)' (#45) from chore/30-sync-release into develop
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-10-16 07:48:20 +03:00
codex-bot 5ca44d16b1 Merge pull request 'chore: bring develop into release sync branch (WealthLedger/main#30)' (#44) from develop into chore/30-sync-release
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-16 07:41:05 +03:00
codex-bot 7da2f90e0a chore: make OpenSpec toolchain explicit and diagnosable (WealthLedger/specs#4) (#43)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-15 06:34:41 +03:00
codex-bot 7ebfc510af spec: clarify SDD applies only on explicit request (WealthLedger/specs#1)
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
2026-10-14 22:43:01 +03:00
codex-bot d5d5480eed spec: add five capability entries to AI_INDEX (WealthLedger/specs#28)
Specs security / security (push) Canceled after 0s
Specs verify / verify (push) Canceled after 0s
2026-10-14 22:43:00 +03:00
codex-bot 4b2806ead5 Merge pull request 'spec: archive three completed changes (main#33, frontend#63, screener)' (#40) from specs/archive-three-changes into develop
Specs security / security (push) Successful in 17s
Specs verify / verify (push) Successful in 10s
2026-10-14 21:46:55 +03:00
ksv741 6efb989ed9 spec: archive three completed changes (main#33, frontend#63, screener)
Conventional commits / commits (pull_request) Successful in 13s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 12s
2026-10-14 21:40:26 +03:00
codex-bot 0ed462d157 Merge pull request 'spec: fix upcoming events date filter default and picker bounds (WealthLedger/frontend#63)' (#39) from specs/63-upcoming-events-date-filter into develop
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 12s
2026-10-14 21:35:02 +03:00
ksv741 b4c0589745 spec: fix upcoming events date filter default and picker bounds (WealthLedger/frontend#63)
Conventional commits / commits (pull_request) Successful in 14s
Specs security / security (pull_request) Successful in 9s
Specs verify / verify (pull_request) Successful in 11s
2026-10-14 21:24:13 +03:00
codex-bot 6c33c770db Merge pull request 'ci: rerun commits proof on PR title edit (WealthLedger/specs#30)' (#38) from fix/30-commits-workflow-edited into develop
Specs security / security (push) Successful in 34s
Specs verify / verify (push) Successful in 11s
2026-10-13 20:33:44 +03:00
ksv741 4e3855fc35 ci: rerun commits proof on PR title edit (WealthLedger/specs#30)
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 14s
2026-10-13 20:18:57 +03:00
codex-bot 6c858a8858 spec: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#37)
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 12s
spec: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:27 +03:00
ksv741 aae23d86f0 ci: validate release PR commits independently (WealthLedger/main#30)
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 23:39:30 +03:00
ksv741 ebc9ca872c chore(release): 0.1.4
Conventional commits / commits (pull_request) Failing after 11s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 23:19:12 +03:00
ksv741 fe295aa73a spec: archive harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
2026-10-12 22:52:44 +03:00
ksv741 e8e68a7ed6 Merge pull request 'spec: add harden-hourly-sync-lifecycle change (WealthLedger/backend#56)' (#33) from fix/56-harden-hourly-sync-lifecycle into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
Reviewed-on: #33
2026-10-12 22:29:16 +03:00
ksv741 77b211186c spec: address hourly-sync review findings (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 14s
Specs security / security (pull_request) Successful in 9s
Specs verify / verify (pull_request) Successful in 11s
2026-10-12 22:19:03 +03:00
ksv741 14174a7bb3 spec: add harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 20:28:58 +03:00
codex-bot 78f01066d2 Merge pull request 'chore: align specs engineering workflow (WealthLedger/main#27)' (#32) from chore/27-engineering-workflow into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 10s
2026-10-12 11:00:37 +03:00
ksv741 7f12327c37 docs: pin specs workflow reference (WealthLedger/main#27)
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 10s
2026-10-12 10:22:13 +03:00
ksv741 a29cbf6aae fix: align specs commitlint workflow (WealthLedger/main#27)
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-10-12 10:21:32 +03:00
ksv741 a441b08378 chore: align specs engineering workflow (WealthLedger/main#27)
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-12 09:21:12 +03:00
codex-bot ea7beea863 Merge pull request 'docs: clarify specs Node bootstrap' (#31) from codex/issue-24-process into develop
Specs security / security (push) Successful in 6s
Specs verify / verify (push) Successful in 9s
2026-10-11 23:23:10 +03:00
ksv741 18518e0d2d docs: clarify specs Node bootstrap
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 11s
2026-10-11 23:06:51 +03:00
codex-bot 0b23fc3013 spec: add stock screener
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
2026-10-11 11:45:09 +03:00
ksv741 fcdc805f6c ci: skip checks on PR metadata edits
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 6s
Specs verify / verify (pull_request) Successful in 8s
2026-10-11 11:36:51 +03:00
ksv741 c132770fbb ci: avoid duplicate PR checks
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 8s
Conventional commits / commits (pull_request) Successful in 9s
2026-10-11 11:24:57 +03:00
ksv741 951f1a9a20 spec: add stock screener
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-11 11:18:21 +03:00
ksv741 329d66e767 spec: finalize wealth-ledger SDD migration
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
2026-10-09 21:14:22 +03:00
codex-bot 90edab6e33 Merge pull request 'spec: define hourly portfolio sync history' (#25) from release/0.1.3 into develop
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Merge hourly portfolio sync history specs
2026-10-09 09:19:38 +03:00
ksv741 b24a686188 spec: define hourly portfolio sync history
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 9s
2026-10-09 09:05:04 +03:00
ksv741 1ee31e3b49 chore(release): 0.1.3
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
2026-10-07 18:13:58 +03:00
ksv741 a1b0e99bdd spec: add reduce-screen-sync-polling change
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 10s
OpenSpec change: screen sync status polling and observe reads run
every five seconds during active sync; terminal state observes
resources immediately.
2026-10-07 14:25:16 +03:00
ksv741 0fd148ffbb chore: run commitlint in commit-msg hook
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 11s
Specs security / security (push) Successful in 8s
Specs verify / verify (push) Successful in 11s
Local commits now get the same commitlint --strict check as CI.
2026-10-07 14:18:14 +03:00
ksv741 33cc4f3e0c fix(deps): patch release tooling advisories
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 10s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 9s
Specs verify / verify (push) Successful in 10s
Scope patched basic-ftp and undici overrides to release-it.

Keep the existing development-dependency security gate.
2026-10-03 19:47:30 +03:00
ksv741 d861de5ead spec: simplify runtime requirements
Specs security / security (push) Failing after 8s
Specs verify / verify (push) Successful in 10s
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Failing after 8s
Specs verify / verify (pull_request) Successful in 10s
Preserve identity isolation and financial integrity while accepting
cross-tab reauthentication and on-demand candle history.

Align portfolio freshness with persisted reads and keep income
consistency at the backend owner.
2026-10-03 11:38:08 +03:00
233 changed files with 16314 additions and 1011 deletions
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+9 -1
View File
@@ -4,6 +4,10 @@ on:
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened, edited]
concurrency:
group: specs-commits-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
commits: commits:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,4 +27,8 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
+6 -2
View File
@@ -2,12 +2,16 @@ name: Specs security
on: on:
push: push:
branches: branches: [develop, main]
- '**'
pull_request: pull_request:
types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
concurrency:
group: specs-security-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
security: security:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+6 -1
View File
@@ -2,8 +2,13 @@ name: Specs verify
on: on:
push: push:
branches: ['**'] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: specs-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
verify: verify:
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
npx --no-install commitlint --edit "$1" --strict
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+17 -6
View File
@@ -2,8 +2,11 @@
Репозиторий содержит существующие behavioral specifications и SDD history. Репозиторий содержит существующие behavioral specifications и SDD history.
Для обычной разработки не создавай и не обновляй planning artifacts ради Для обычной разработки не создавай и не обновляй planning artifacts ради
процесса. Требование пользователя определяет работу; OpenSpec применяется процесса. До edits найди/создай подходящую Gitea Issue, проверь AC и ветку;
только по явному запросу. чтение допускается без Issue. Выбор lightweight/SDD — по
[Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`);
OpenSpec lifecycle начинается только после явного решения Change Owner.
Если нужна существующая behavioral spec, выбери точный target через Если нужна существующая behavioral spec, выбери точный target через
[AI_INDEX](AI_INDEX.md). Specs не являются вторым editable HTTP OpenAPI: [AI_INDEX](AI_INDEX.md). Specs не являются вторым editable HTTP OpenAPI:
@@ -16,11 +19,19 @@ anchors и `git diff --check`; application gates принадлежат affected
Если явно запрошена работа через OpenSpec, следуй Если явно запрошена работа через OpenSpec, следуй
[локальным инструкциям OpenSpec](README.md#начало-работы) [локальным инструкциям OpenSpec](README.md#начало-работы)
и используй store `wealth-ledger-specs`. Перед Node-командами активируй и используй store `wealth-ledger-specs`. Перед Node-командами активируй
местную `.nvmrc` через `nvm use --silent`. местную `.nvmrc`: если `nvm` не загружен в non-interactive shell, загрузи
установленный `$HOME/.nvm/nvm.sh`, затем выполни `nvm use --silent` из specs.
Если `nvm.sh` недоступен, остановись; не используй системный Node.
## Ветки и релизы ## Ветки и релизы
Работа — в отдельной ветке от `develop`, затем PR в `develop`; в `main` напрямую Работа — в issue-linked ветке от `develop`, затем PR в `develop`; в `main` напрямую
не пушить. Release/hotfix и синхронизация веток — по [README](README.md#релизы). не пушить. PR сливается squash или rebase-merge; merge-коммит не используется.
Commits и заголовки PR — Conventional Commits; для спецификаций допустим `spec:`. `main` обновляется только fast-forward из `develop`, поэтому `origin/main`
всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Релиз — по [README](README.md#релизы).
Commits и заголовки PR — Conventional Commits со ссылкой на Issue (кроме release);
для спецификаций допустим `spec:`.
Перед подготовкой релиза прочитай местный README; версии вручную не повышать. Перед подготовкой релиза прочитай местный README; версии вручную не повышать.
+6
View File
@@ -16,8 +16,13 @@ types. CI каждого участника использует только е
| --- | --- | | --- | --- |
| Auth/session/refresh/error contract | [User authentication](openspec/specs/user-authentication/spec.md) | | 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 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) | | Счета/сводки | [Brokerage accounts](openspec/specs/brokerage-accounts/spec.md) |
| Portfolio positions/totals/stale | [Portfolio](openspec/specs/portfolio-view/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) | | Операции/cursor/filters | [Account ledger](openspec/specs/brokerage-account-ledger/spec.md) |
| Будущие выплаты | [Upcoming events](openspec/specs/brokerage-account-upcoming-events/spec.md) | | Будущие выплаты | [Upcoming events](openspec/specs/brokerage-account-upcoming-events/spec.md) |
| Доходы и периоды | [Income analytics](openspec/specs/brokerage-account-income-analytics/spec.md) | | Доходы и периоды | [Income analytics](openspec/specs/brokerage-account-income-analytics/spec.md) |
@@ -25,6 +30,7 @@ types. CI каждого участника использует только е
| Каталог компонентов | [UI catalog](openspec/specs/frontend-ui-catalog/spec.md) | | Каталог компонентов | [UI catalog](openspec/specs/frontend-ui-catalog/spec.md) |
| Loading/skeleton | [Loading presentation](openspec/specs/frontend-loading-presentation/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) | | 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) | | 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) | | OpenAPI generation | [API contract generation](openspec/specs/api-contract-generation/spec.md) |
| Logging/request context | [Structured logging](openspec/specs/backend-structured-logging/spec.md) | | Logging/request context | [Structured logging](openspec/specs/backend-structured-logging/spec.md) |
+89
View File
@@ -1,5 +1,94 @@
# Changelog # Changelog
## [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
* add five capability entries to AI_INDEX (WealthLedger/specs[#28](https://git.personal.ksv741.ru/WealthLedger/specs/issues/28)) ([d5d5480](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d5d5480eedbc25c77c52b3b477de37e373beb8d9))
* align user-authentication password requirement with ASCII 8-72 policy (WealthLedger/specs[#46](https://git.personal.ksv741.ru/WealthLedger/specs/issues/46)) ([#47](https://git.personal.ksv741.ru/WealthLedger/specs/issues/47)) ([1bd6181](https://git.personal.ksv741.ru/WealthLedger/specs/commit/1bd618158588871f1801bfc6bead82fba31ce0ae))
* archive quiet-user-feedback and sync canonical specs ([#50](https://git.personal.ksv741.ru/WealthLedger/specs/issues/50)) ([f0478c6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/f0478c6d6b5a5f259ea766276295758b34d240b3))
* archive three completed changes (main[#33](https://git.personal.ksv741.ru/WealthLedger/specs/issues/33), frontend[#63](https://git.personal.ksv741.ru/WealthLedger/specs/issues/63), screener) ([6efb989](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6efb989ed9051981a314c8eb5714dffa88195a80))
* clarify SDD applies only on explicit request (WealthLedger/specs[#1](https://git.personal.ksv741.ru/WealthLedger/specs/issues/1)) ([7ebfc51](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7ebfc510afb7440f85ecf95e2891d84c1b8ad843))
* fix upcoming events date filter default and picker bounds (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/specs/issues/63)) ([b4c0589](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b4c0589745e0d1fb6b482addbea82d06c4924dad))
* reconcile quiet-user-feedback with source-state requirements ([#50](https://git.personal.ksv741.ru/WealthLedger/specs/issues/50)) ([5a7e319](https://git.personal.ksv741.ru/WealthLedger/specs/commit/5a7e319cafd8d9b6861b5a6bab7085bb57b08576))
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/specs/issues/33)) ([#37](https://git.personal.ksv741.ru/WealthLedger/specs/issues/37)) ([6c858a8](https://git.personal.ksv741.ru/WealthLedger/specs/commit/6c858a8858dfdfc5c9607b3f925de5900ef1c903))
### CI
* rerun commits proof on PR title edit (WealthLedger/specs[#30](https://git.personal.ksv741.ru/WealthLedger/specs/issues/30)) ([4e3855f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/4e3855fc3526e2b0b7ba80fd23b996d9993edcf5))
### Maintenance
* adopt linear git model in specs instructions ([#48](https://git.personal.ksv741.ru/WealthLedger/specs/issues/48)) ([06d08e2](https://git.personal.ksv741.ru/WealthLedger/specs/commit/06d08e29724e6cbe95eeca5ee1e6af7ec365a559))
* make OpenSpec toolchain explicit and diagnosable (WealthLedger/specs[#4](https://git.personal.ksv741.ru/WealthLedger/specs/issues/4)) ([#43](https://git.personal.ksv741.ru/WealthLedger/specs/issues/43)) ([7da2f90](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7da2f90e0a6a7e0e5cb40b371c965a772aaa5db2))
## [0.1.4](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.3...v0.1.4) (2026-10-12)
### Bug Fixes
* align specs commitlint workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([a29cbf6](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a29cbf6aae7ebe48a51ae1979ff3f30b9fa8e305))
### Documentation
* clarify specs Node bootstrap ([18518e0](https://git.personal.ksv741.ru/WealthLedger/specs/commit/18518e0d2daecb843fea1b4d55d1c7d2cabf24f6))
* pin specs workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([7f12327](https://git.personal.ksv741.ru/WealthLedger/specs/commit/7f12327c37055ba903f962b1220138da9c7edd06))
### Specifications
* add harden-hourly-sync-lifecycle change (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([14174a7](https://git.personal.ksv741.ru/WealthLedger/specs/commit/14174a7bb335152c8cc76d9a6bbaefaaaa58b22f))
* add stock screener ([951f1a9](https://git.personal.ksv741.ru/WealthLedger/specs/commit/951f1a9a20a09695e2ff85d1ee96632e98a202d8))
* address hourly-sync review findings (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([77b2111](https://git.personal.ksv741.ru/WealthLedger/specs/commit/77b211186c297df4187e044804786ffc9096ce19))
* archive harden-hourly-sync-lifecycle change (WealthLedger/backend[#56](https://git.personal.ksv741.ru/WealthLedger/specs/issues/56)) ([fe295aa](https://git.personal.ksv741.ru/WealthLedger/specs/commit/fe295aa73ab5bc9d826067892930b7161fb4f1b9))
* define hourly portfolio sync history ([b24a686](https://git.personal.ksv741.ru/WealthLedger/specs/commit/b24a6861887e831ef2d73a97f36cc213be2be0b8))
* finalize wealth-ledger SDD migration ([329d66e](https://git.personal.ksv741.ru/WealthLedger/specs/commit/329d66e767f7e89a40b96bb8e5b499f39f67feb6))
### CI
* avoid duplicate PR checks ([c132770](https://git.personal.ksv741.ru/WealthLedger/specs/commit/c132770fbb4a4e48cae0a4ceca7407bb6a768ded))
* skip checks on PR metadata edits ([fcdc805](https://git.personal.ksv741.ru/WealthLedger/specs/commit/fcdc805f6c1b3be6a20a7e858e3e80cbd83a9121))
### Maintenance
* align specs engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/specs/issues/27)) ([a441b08](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a441b083785d8f50c42dea543e684b23f27732a2))
## [0.1.3](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.2...v0.1.3) (2026-10-07)
### Bug Fixes
* **deps:** patch release tooling advisories ([33cc4f3](https://git.personal.ksv741.ru/WealthLedger/specs/commit/33cc4f3e0c20ed3d6f5814cdc609724ffb8564ac))
### Specifications
* add reduce-screen-sync-polling change ([a1b0e99](https://git.personal.ksv741.ru/WealthLedger/specs/commit/a1b0e99bddb91f7b7ca729ddf6b7d6c688fdca41))
* simplify runtime requirements ([d861de5](https://git.personal.ksv741.ru/WealthLedger/specs/commit/d861de5ead41b4977083b4aee08fc668c35e5b34))
### Maintenance
* run commitlint in commit-msg hook ([0fd148f](https://git.personal.ksv741.ru/WealthLedger/specs/commit/0fd148ffbb39ea883a7e7799aa1b87c93b31fa17))
## [0.1.2](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.1...v0.1.2) (2026-09-26) ## [0.1.2](https://git.personal.ksv741.ru/WealthLedger/specs/compare/v0.1.1...v0.1.2) (2026-09-26)
### Specifications ### Specifications
+57 -20
View File
@@ -1,24 +1,36 @@
# WealthLedger Specs # WealthLedger Specs
Этот репозиторий — единственный versioned SDD authority рабочей области. Canonical OpenSpec store `wealth-ledger-specs` расположен в `openspec/` (`specs/openspec` от workspace), новые changes используют project-local schema `wealth-ledger-sdd`. Этот репозиторий — единственный versioned SDD authority рабочей области. Canonical OpenSpec store `wealth-ledger-specs` расположен в `openspec/` (`specs/openspec` от workspace). Новые changes используют `wealth-ledger-sdd-v2`; существующие active changes с `schema: wealth-ledger-sdd` завершаются по legacy schema без автоматической миграции.
## Начало работы ## Начало работы
OpenSpec применяется по явному запросу согласно [AGENTS](AGENTS.md). Proposal, Delta Specs, Design и Tasks создаются последовательно по одному после approval Change Owner. Для любой команды, читающей или изменяющей change, явно передавайте `--store wealth-ledger-specs`. OpenSpec применяется после явного решения Change Owner согласно [AGENTS](AGENTS.md). Для команд, читающих или изменяющих change, явно передавайте `--store wealth-ledger-specs`. Структурная готовность CLI не означает approval: перед каждым downstream artifact нужно явное решение Change Owner.
Design должен оценить `frontend`, `backend`, `specs`, `docs` в Delivery Map и при обязательном trigger включить Solution Assessment. Docs описывает as-built state и не становится параллельным store требований. | Change | Lifecycle |
| --- | --- |
| Active `schema: wealth-ledger-sdd` | Proposal → Delta Specs → Design → Tasks → Apply; действующие approvals сохраняются. Legacy Design оценивает `frontend`, `backend`, `specs`, `docs` в Delivery Map. |
| New `schema: wealth-ledger-sdd-v2` | Proposal → Delta Specs → System Analysis → Design + QA Analysis → Docs Impact → Tasks → Apply. Design и QA Analysis готовы после System Analysis; Docs Impact ждёт обоих. |
Для changes без изменения observable behavior используется `skip_specs: true`. В v2 System Analysis/QA Analysis/Docs Impact всё равно фиксируют только применимое содержание и обоснованное N/A, а не фиктивные contracts или tests. `specs/openspec/specs/` хранит нормативное поведение, `docs/` — текущий AS-IS, backend-generated OpenAPI — текущий executable HTTP contract. Target contracts остаются в change до архива; docs обновляются только по согласованному Docs Impact через `[docs]` tasks. После завершения последнего legacy change эту переходную заметку можно удалить отдельным изменением.
## Проверка ## Проверка
Для native validation из этого репозитория: Для native validation из этого репозитория:
```bash ```bash
npm run check:openspec
openspec schema validate wealth-ledger-sdd --verbose 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 <change> --type change --strict --store wealth-ledger-specs
openspec validate --specs --strict --store wealth-ledger-specs openspec validate --specs --strict --store wealth-ledger-specs
``` ```
Structural validation не заменяет semantic verification и independent read-only review перед archive. 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.
Node задаётся собственной `.nvmrc`; после `nvm use --silent` выполните `npm ci`. Node задаётся собственной `.nvmrc`; после `nvm use --silent` выполните `npm ci`.
`npm run verify` проверяет diff. OpenSpec validation остаётся отдельной проверкой `npm run verify` проверяет diff. OpenSpec validation остаётся отдельной проверкой
@@ -27,35 +39,60 @@ Node задаётся собственной `.nvmrc`; после `nvm use --sil
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. `develop`; общие правила — в Engineering Workflow по ссылке из AGENTS.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что ```text
`develop` содержит последний релиз. Создай `release/<name>` от проверенного <type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в ```
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` `verify`, `security`, `commits` выполни fast-forward-only merge. Если
изменился, согласуй историю и повтори проверки кандидата. `develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией 4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
merge создай отсутствующий тег без нового bump; до merge исправь и проверь Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
существующий кандидат без повторного повышения версии. без нового bump; до merge исправь и проверь существующий кандидат без
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+9 -1
View File
@@ -1,4 +1,12 @@
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, defaultIgnores: false,
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
"type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]],
"references-empty": [2, "never"],
},
}; };
+1 -1
View File
@@ -1 +1 @@
1.10.0 1.13.1
@@ -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-concentration/spec.md` (28) и `specs/openspec/changes/account-top-positions/specs/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-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/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-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-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-concentration/spec.md`; **PVΔ** — `specs/openspec/changes/account-top-positions/specs/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-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/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/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-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-concentration/spec.md` (28 Requirement), `specs/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,54 @@
# 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 "да, все окей, идем дальше" |
Примечание к G2 (2026-10-30): GIVEN «сектор и бренд актива позиции ещё ни разу не получены» в Scenario «Недоступность сектора и эмитента не нарушает Snapshot» (finding N1) и замена строки design-обязательства в failure-model (N2) внесены основным агентом до записи approved G2 и входят в утверждённую версию.
## Descoped Work
| Task | Причина | Gate |
| --- | --- | --- |
## 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-concentration/spec.md`: вкладка «Концентрация» — разрезы, ранжирование топ-5, «Прочее», доли и суммарная доля топ-5, выбор валюты, правила учёта позиций, состояния.
### Modified Capabilities
- `specs/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-view/spec.md`, требования «Нормализованный состав Portfolio», «Точные значения и валютные агрегаты».
- [F] Карточки позиций показывают долю позиции среди позиций той же валюты. Источник: `specs/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
- [ ] 3.1 [specs] Independent Review: read-only review backend и frontend PR против Specs, design и analysis
- **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
- [ ] 3.2 [specs] QA Verification: прогон test-cases на локальном backend + frontend
- **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
- [ ] 3.3 [specs] QA Verification: exploratory Q-D1/Q-D2/Q-F3 на реальном токене владельца
- **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
- [ ] 3.4 [backend] Verification: откат backend-bundle на копии данных до G4
- **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
- [ ] 5.1 [specs] Verification: запись G4 в process-record с evidence задач 1–4
- **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
- [ ] 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

@@ -0,0 +1,40 @@
# Completion Evidence: add-manual-bank-cards
## Finalization Authority
Change Owner authorized Finalization, canonical sync and archive after a current
independent read-only Validation PASS. The PASS found no Critical or Important
findings after the monthly-summary presentation correction.
## Delivery
| Area | Result | Delivery commit |
| --- | --- | --- |
| frontend | Manual card screens and summaries; Finalization correction shows exact expenses, refunds and net totals with a regression test. | `ff67a76`, `d3ac8c2`, `734c5bd` |
| backend | Bank-card persistence, calculations, API and isolated verification. | `e6f58ef` |
| docs | Cards overview states the three displayed summary totals. | `3a27268` |
| specs | Approved legacy Proposal, Delta Specs, Design, completed Tasks and Finalization evidence; native sync creates canonical behavior spec. | `834f2d1` plus finalization/archive commit |
## Observed Verification
| Area | Command or review | Result |
| --- | --- | --- |
| frontend | `npm run test:run -- src/entities/bank-card/ui/BankSummary/BankSummary.view.test.tsx` | PASS: 1 file, 1 test |
| frontend | `npm run typecheck` | PASS |
| docs | `npm run verify` | PASS |
| specs | `openspec schema validate wealth-ledger-sdd --verbose` | PASS |
| specs | `openspec schema validate wealth-ledger-sdd-v2 --verbose` | PASS |
| specs | `openspec validate add-manual-bank-cards --type change --strict --store wealth-ledger-specs` | PASS |
| specs | `openspec validate --specs --strict --store wealth-ledger-specs` | PASS: 25 specs, 0 failed |
| all affected areas | `git diff --check` | PASS |
## Independent Validation
PASS. The review compared Proposal, all 17 Delta Requirements, Design and
completed Tasks with current backend calculations/DTOs, shared frontend summary
rendering and cards documentation. It verified that `/cards` and `/cards/:id`
share the corrected summary view and found no Critical or Important findings.
Known non-blocking limitation: a plain Prisma fresh migration deploy still needs
`RUST_LOG=info`; the verified migration path and its evidence remain recorded in
the change Tasks.
@@ -0,0 +1,31 @@
# Completion Evidence: containerized-application-delivery
## Outcome
The local and disposable single-server container delivery path is implemented
and verified. External publication and target-server gates remain unavailable in
the current environment and are recorded as blockers rather than represented as
successful results.
## Observed Results
- Frontend and backend development/production images build and run locally.
- Development HMR/watch, same-origin API routing, production SPA fallback,
readiness, persistence, migration failure handling, backup and isolated
restore were verified on disposable data.
- Docs verification and Compose configuration checks passed.
- No production data, secrets, registry credentials or real server were used.
## External Blockers
The following approved implementation tasks remain externally blocked:
- Docker-capable Gitea runner acceptance;
- registry publisher and server pull-only credentials;
- authenticated candidate publication and digest pull;
- target-server Docker/Compose verification;
- first server rollout pair confirmation.
These tasks remain unchecked in the archived checklist. The change is archived
as externally blocked after owner authorization; this is not a claim that the
external gates passed and does not authorize production rollout.
@@ -92,7 +92,7 @@ secrets и опубликованные release tags не используютс
- **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Обновление API сохраняет целостность данных`; `application-delivery` — `Восстановление не выдаёт откат приложения за откат данных`. - **Covers:** `application-delivery` — `Локальная среда воспроизводит границы поставленного приложения`; `application-delivery` — `Поставка допускает независимое обновление компонентов`; `application-delivery` — `Обновление API сохраняет целостность данных`; `application-delivery` — `Восстановление не выдаёт откат приложения за откат данных`.
- **Verification:** dry-run runbook на disposable data проходит без неописанного шага; frontend-only и backend-only paths не пересоздают другой component; restore требует отдельного решения и сообщает о потере данных после backup. - **Verification:** dry-run runbook на disposable data проходит без неописанного шага; frontend-only и backend-only paths не пересоздают другой component; restore требует отдельного решения и сообщает о потере данных после backup.
- [ ] 5.3 [docs] Выполнить docs repository completion gate. - [x] 5.3 [docs] Выполнить docs repository completion gate.
- **Covers:** `N/A` — repository-owned проверка durable as-built documentation и ссылок без нового runtime behavior. - **Covers:** `N/A` — repository-owned проверка durable as-built documentation и ссылок без нового runtime behavior.
- **Verification:** `npm ci`, `npm run verify`, `git diff --check`; проверить Mermaid rendering, взаимные ADR/architecture/runbook links и отсутствие credentials или host-private values. - **Verification:** `npm ci`, `npm run verify`, `git diff --check`; проверить Mermaid rendering, взаимные ADR/architecture/runbook links и отсутствие credentials или host-private values.
@@ -0,0 +1 @@
schema: wealth-ledger-sdd
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd
created: 2026-10-07
@@ -0,0 +1,96 @@
## Context
Backend — один NestJS process и SQLite. Сейчас screen-driven синхронизация раскладывает запросы экранов на source work; владельцы membership, portfolio, operations, forecast, catalog и candles используют reservations, attempt/revision fences и короткие транзакции без RPC внутри них. Текущий portfolio snapshot перезаписывается и каскадно удаляется при замене подключения или исчезновении счёта. `TbankSyncService` принимает экранный POST; frontend `screen-sync` запускает его и наблюдает status. Справочные данные, результаты поиска и отрицательные lookup живут в сохранённых таблицах, а detail является зависимостью событий и рыночных данных. Новый режим требует смены инициатора, не второй конкурирующей системы записи.
## Goals / Non-Goals
**Goals:** сохранить единственного владельца публикации каждого источника; сделать расписание устойчивым к рестарту в рамках одного процесса; связать точки истории с успешной публикацией портфеля и подтверждённым доступом; отделить сетевое чтение интерфейса от брокерского обновления; перевести подробные сведения инструментов на онлайн-источник без потери сохранённых рыночных и личных данных.
**Non-Goals:** график и публичная история точек, новый механизм синхронизации между процессами, повторное сканирование полной рыночной истории и уже полных операций, изменение банковских карт и auth-refresh.
## Decisions
### Один durable clock поверх существующих source owners
В `tbank-sync` вводится одиночный backend scheduler с persisted последней попыткой для текущего подключения: начало, завершение/состояние и диагностические разделы без токенов. При успешном PUT подключения фоновой работе передаётся новая граница; при старте process scheduler сверяет её и последнее начало, не опираясь на frontend GET. Проверка часовым таймером только предлагает работу; атомарное резервирование сверяет user/revision, время начала предыдущей попытки и отсутствие ещё активной попытки. Во время работы новое резервирование не допускается; пересечённый срок пропускается, а не исполняется «вдогонку». После рестарта незавершённая попытка старого process становится прерванной, но её время начала остаётся основанием следующего запуска; новый process не публикует её поздние результаты. Успешное подключение начинает новую первую попытку, даже если прежний токен был заменён незадолго до срока. Штатный процесс инициализирует таймер и освобождает его при shutdown.
Scheduler делает один обход подтверждённого membership, затем даёт portfolio owner обновить каждый доступный счёт, operations owner — только изменяемый хвост, forecast/source owners — события бумаг состава. Передаёт доступ и ревизию владельцам, не пишет их личные наборы сам и не держит транзакцию при сетевых вызовах. Результаты отдельных источников дают общий итог `succeeded/partial/failed` и безопасные идентификаторы счёта/раздела. При недействительном токене применяется существующая блокировка личных ответов. Альтернатива — reuse screen intent с поддельным экраном `accounts`: он не покрывает операции и события для каждого счёта и случайно включает ненужные зависимости; выделение фонового orchestration над теми же owners точнее.
### История отдельно от revision, но внутри атомарной публикации
Состояние истории принадлежит `(userId, accountId)` и переживает смену token/revision; текущий snapshot и source work по-прежнему принадлежат `(userId, revision, accountId)`. Точки используют точную десятичную строку, ISO-валюту и фактическое время успешной публикации, без состава позиций. Проверка полноты оценки всех позиций предшествует вставке любых валютных точек; пустой безвалютный портфель не даёт строк. Для однозначности повторной публикации точка привязана к идентификатору успешной попытки portfolio, а её запись и замена snapshot происходят в одной короткой транзакции после conditional attempt claim. При потере revision/доступа вся транзакция отменяется. Неоценённый состав сохраняется текущим без исторических точек.
Видимость истории определяется текущим подтверждённым membership и признаком удаления счёта, а не одной лишь идентичностью исторических строк. Успешное подтверждение списка, успешный PUT нового token и DELETE обновляют пометки доступа вместе со сменой текущей границы; неуспешный PUT/GET membership не меняет их. Чтение возможной истории в будущем обязано проверять session, текущее неотклонённое подключение и активное подтверждение конкретного accountId: `deletedAt = null` само по себе не является полномочием. Это позволяет сохранить прошлые точки, не воскресить приватный снимок прежней revision и не раскрыть историю между отключением и повторным подтверждением. Поскольку endpoint истории сейчас не предусмотрен, API для её чтения не проектируется.
### Сначала разделить источники по смыслу, затем удалить screen import
Обычные GET инвестиционных областей становятся локальными для membership/portfolio и изменяемых операций/прогнозов; их freshness отражает час, а ошибки попыток живут отдельно. Исключения — первичная загрузка отсутствующего исторического интервала операций и впервые запрошенного диапазона свечей; покрытие остаётся владельцем операций/candles. Текущие quotes/незавершённые свечи обновляются по запросу не чаще часа. Удаление ручного screen POST и его наблюдения следует после перехода всех consumers на чистые reads и статус последнего обхода. Иначе нынешние `sync=observe` GET и retry-кнопка могут обходить новый график; транспортный retry сохраняется, но не вызывает брокерский импорт. Публичный DTO статуса описывает только последнюю попытку и читается под той же auth/access boundary. Backend публикует OpenAPI как контракт, frontend обновляет свой snapshot из него отдельно.
### Онлайн-справочник без разрушения зависимостей
Публичный instrument detail, company и catalog search идут через существующие T-Bank/MOEX/NCR clients по запросу с проверкой UID/ticker и сохранением различия «не найдено / неоднозначно / источник недоступен». Общая активная работа может коалесцировать одинаковые сетевые вызовы в процессе, но отдельная постоянная копия справочника и отрицательного lookup не создаётся. Для прогноза необходимые атрибуты запрашиваются внутри его работы; ошибка не заменяет последний успешный forecast. Для сохранённых quotes, candle coverage, событий и portfolio labels остаётся только минимальная стабильная identity, нужная для ключей и чтения; ссылки и пользовательские данные не зависят от удаляемого detail payload. Старые справочные записи выводятся из использования до рассмотрения их удаления; каскады не должны уничтожать свечи. При сбое онлайн-источника блок detail показывает отдельную ошибку, остальные сохранённые данные доступны в своих границах.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — на инвестиционных экранах убираются ручные команды синхронизации, добавляются 30-минутное перечитывание и статус последнего обхода на кабинете. Макет графика не нужен. Пакет полностью inline; локальные PNG/SVG не требуются.
**Package revision:** `1` (этот Design).
**Screens, states and interactions:**
| Экран / блок | Без результата | Данные сохранены | Сбой / частичный успех | Действие |
| --- | --- | --- | --- | --- |
| Кабинет/счета | штатная первая загрузка и отсутствие доступных счетов | прежние карточки и время обновления | сообщение о последнем обходе: состояние, время, названия проблемных счетов/разделов без token/error payload | переход к счёту или настройкам; кнопки синхронизации нет |
| Портфель/операции/доход/события | локальная загрузка либо недостающий исторический период | сохранённые значения и исходное время остаются при перечитывании | server-side stale отдельно от transport error; прежние значения не становятся новым snapshot | повтор только HTTP-чтения при сетевой ошибке |
| Карточка инструмента и поиск | справочный блок ожидает онлайн-ответ; свечи могут впервые загружаться отдельно | сохранённые позиции, операции, подписи, котировки/свечи и события сохраняют самостоятельные состояния | ошибка справочного блока не скрывает независимые данные; подпись snapshot не обещает свежие подробности | открытие и повтор HTTP-чтения сведений без команды синхронизации личных счетов |
30-минутное перечитывание действует только пока инвестиционный экран открыт; для онлайн-справочного блока оно также означает новый запрос сведений. Время последней попытки не подменяет `lastSuccessfulAt` отдельного счёта. Текст частичного успеха включает доступный список проблемных разделов; не показываются сырые upstream сообщения.
**Responsive behavior:** использовать существующие адаптивные контейнеры и ширины списков/карточек; строка статуса переносит названия счетов и разделов на узком экране, не закрывает содержимое и имеет доступное текстовое обозначение помимо цвета. Новых плотных таблиц, графиков или мобильной навигации нет.
**Reused source and differences:** существующие as-built страницы кабинета, портфеля, операций и карточек инструмента сохраняют компоновку; новый пакет не зависит от изменяемой внешней ссылки либо архивного макета. Отличия перечислены выше: статус обхода вместо ручного refresh и локальная ошибка online detail. Идентификатор immutable archived reuse не применяется: новый статус и новые переходы состояний описаны здесь inline, не объявляются точной копией прошлой версии.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Убрать screen-triggered mutation и инвестиционные sync-кнопки, сохранить transport retry, добавить периодическое чтение и статус на кабинете, независимо показывать ошибку online detail. |
| backend | `affected` | Durable scheduler, учёт частичного результата и доступа, точки истории, локальные read boundaries, онлайн-справочник и опубликованный API статуса. |
| specs | `affected` | Нормативные delta requirements для нового расписания, истории и обновлённых инвестиционных/клиентских сценариев. |
| docs | `affected` | Обновить объяснение свежести, областей и глоссарий, зафиксировать новое решение относительно ADR 017 без переписывания его исторического контекста. |
## Solution Assessment
| Вариант | Fit и maturity | Security / data integrity | Operating cost и ограничения | Migration cost |
| --- | --- | --- | --- | --- |
| Внешний cron/worker с отдельным экземпляром | Зрелый для fleet, но дублирует приложение при single-process SQLite | Нужны распределённые lock/lease и граница токенов между процессами | Отдельный deployment и координация | Высокая; против текущей single-process модели |
| Nest scheduling dependency | Зрелые cron decorators/timers, но сама библиотека не хранит результат попыток | В любом случае нужны persisted CAS/fences для restart и подключения | Ещё один runtime package и жизненный цикл без выигрыша от cron expression | Небольшая, но ненужная зависимость |
| Native Node timer + существующие SQLite/Prisma reservations | Подходит для одного процесса; точность минутных тиков не подменяет persisted время начала | Conditional reservation + revision/attempt fence, история в DB transaction | Нет нового сервиса или пакета; при нескольких процессах требуется отдельная координация | Умеренная, повторное использование владельцев источников |
Выбран native timer с durable CAS: уже существующий `TbankSyncWork` и SQLite дают необходимые primitives, но сам таймер не является гарантом уникальности. Для истории использовать уже установленный Prisma/SQLite вместо time-series базы; данные малы, rollback и backup живут в существующем deployment. Для справочных сведений использовать действующих integration clients и online reads; новая cache/service boundary не оправдана, а долговременное кэширование прямо противоречит принятым требованиям. Отказ от него повышает зависимость detail от T-Bank, поэтому ошибки изолируются от сохранённых финансовых наборов. Перед настройкой фактического числа upstream вызовов требуется сверить действующие первичные ограничения провайдера и измерить размер/прирост локальной базы на реальном объёме; произвольные внешние числовые лимиты не фиксируются как допущение.
## Risks / Trade-offs
- При простое backend точки пропускаются; нет искусственной интерполяции и накопленной очереди RPC.
- Один timer без отдельной межпроцессной аренды небезопасен для нескольких экземпляров: deployment сохраняет единственный process.
- Переход на онлайн-справочник увеличивает latency и чувствительность detail/search к upstream; нельзя распространять ошибку на сохранённый портфель или его события.
- Исчезновение/возврат счёта и одновременная смена token требуют fence внутри коротких транзакций; `deletedAt` без актуального подтверждения membership не даёт права чтения.
- Удаление таблиц справочника до развязки FK/dependencies может стереть историю свечей; destructive cleanup откладывается до проверки сохранности рыночных данных.
- Старый frontend и новый backend должны сосуществовать в переходе: публикация DTO статуса предшествует удалению screen POST; потребителю передаётся явный OpenAPI artifact.
## Migration Plan
1. Добавить историю и metadata последней попытки совместимым расширением SQLite; не переписывать уже применённые migrations и не выполнять reset на рабочей базе. Привязать запись истории к существующему portfolio publisher и видимость к подтверждённому membership/connection lifecycle.
2. Ввести backend-only расписание поверх существующих source owners и read-only статус; включить первичную загрузку и восстановление после рестарта при сохранённом времени попытки. Только после проверки idempotency и отказов перевести GET на read-only для текущих личных данных.
3. Развязать online reference от долговременных detail/search/lookup записей, сохранив минимальные identity и таблицы quotes/candles/forecast; сначала перевести readers, затем совместимой миграцией удалять лишь действительно неиспользуемые справочные payload, не каскадируя рыночную историю.
4. Получить опубликованный backend OpenAPI и отдельно обновить frontend query/статус/кнопки в рамках согласованного contract. Затем убрать неиспользуемый screen-triggered endpoint; зафиксировать изменение ADR и as-built страниц. Измерить рост SQLite и provider call volume до изменения частоты/лимитера.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `required` |
| Trigger | Нарушение доступа к истории после замены token, повторная публикация точек, порча сохранённых свечей при переходе справочника либо несовместимость нового frontend с API статуса. |
| Actions | Остановить scheduler, вернуть предыдущие совместимые сборки из проверенного комплекта и восстановить DB из согласованного backup перед destructive переходом; при additive стадии допустимо вернуть старый runtime с сохранением новых неиспользуемых таблиц. Не возобновлять прежний screen-triggered режим одновременно с включённым hourly scheduler. |
| Data safety | Сохранить исходные snapshots, coverage, forecast и точки истории; никогда не удалять или заново создавать production SQLite ради rollback. Перед очисткой справочника обеспечить проверенный backup/restore и доказать отсутствие каскадов в candles. |
| Verification | Проверить на копии с представительными данными доступность прежних личных данных только у текущего подключения, идемпотентность точек, сохранность candles/coverage и совместимость опубликованного API до возврата трафика. |
@@ -0,0 +1,32 @@
## Why
Сейчас актуализация инвестиционных данных зависит от открытия экранов и ручного действия. Неактивный кабинет не получает новые данные, а долгосрочную динамику стоимости счёта восстановить по последнему снимку невозможно. Нужны предсказуемое часовое обновление независимо от frontend и накопление пригодных для будущего графика оценок. Согласованный объём — [issue #22](https://git.personal.ksv741.ru/WealthLedger/main/issues/22).
## What Changes
- Обновлять доступные счета, состав и стоимость портфелей, актуальные операции и будущие события по бумагам портфеля раз в час силами backend; первое обновление выполнять после подключения. Сохранённые данные отдавать сразу, сохраняя последнее успешное состояние при ошибке и показывая результат последней попытки, включая частичный успех.
- Накапливать оценку стоимости каждого счёта по валютам на фактическое время успешного получения полной оценки, без конвертации и подмены отсутствующей цены нулём. Сохранять историю при смене токена и отключении, скрывать её без подтверждённого доступа и продолжать при восстановлении доступа к тому же счёту.
- Убрать ручную синхронизацию с T-Bank и обновление личных данных при открытии инвестиционных экранов. Открытые экраны перечитывают сохранённые данные каждые 30 минут. Сохранить первичную загрузку отсутствующих исторических интервалов и диапазонов свечей, но не перечитывать полностью загруженную старую историю автоматически.
- Получать справочные данные инструмента и результаты поиска онлайн без отдельного долговременного хранения; сохранить подписи последнего снимка портфеля, события, котировки и свечи согласно их разным правилам актуализации.
- Вне объёма: сам график и публичное чтение его точек, валютная конвертация, многопроцессное расписание, автоматическая повторная сверка уже полных старых интервалов операций и свечей, а также изменение действий для ручных банковских карт и повторного чтения после сетевой ошибки.
## Capabilities
### New Capabilities
- `specs/hourly-investment-sync/spec.md`: независимый от открытых экранов часовой обход и наблюдаемый результат последней попытки.
- `specs/portfolio-value-history/spec.md`: точная история оценки по счёту и валюте с учётом полноты и границы доступа.
- `specs/online-instrument-information/spec.md`: получение справочных сведений и поиска онлайн без сохранённой самостоятельной копии, отдельно от котировок, свечей и событий.
### Modified Capabilities
- `specs/portfolio-view/spec.md`: чтение последнего снимка без запуска синхронизации, часовая свежесть, отсутствие ручной синхронизации и сохранение доступных подписей позиций.
- `specs/brokerage-accounts/spec.md`: быстрое чтение сохранённых счетов и сводок, видимость частичных ошибок и периодическое перечитывание экрана.
- `specs/tbank-connection/spec.md`: первая загрузка после подключения и сохранение скрытой истории стоимости при отключении/замене токена.
- `specs/brokerage-account-upcoming-events/spec.md`: обновление прогноза по расписанию с сохранением последнего полного результата при сбое.
- `specs/frontend-data-resilience/spec.md`: разделение повтора HTTP-чтения и запрещённой ручной синхронизации при часовом обновлении.
- `specs/brokerage-account-ledger/spec.md`: первичная загрузка отсутствующего периода без повторной сверки уже полного исторического периода при открытии страницы.
## Impact
Пользователь быстрее открывает инвестиционные экраны и видит время и возможную неполноту последней синхронизации; открытый экран получает новые сохранённые данные без команды обновления. Затронуты инвестиционные экраны, фоновое обновление и хранение личных данных, правила доступа после смены подключения, отображение ошибок и онлайн-получение информации об инструментах. Переход требует согласовать действующие требования, которые пока предписывают короткие сроки свежести и ручные повторы.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Отсутствующий исторический интервал загружается только впервые
Система MUST позволять авторизованному User при первом открытии ещё не загруженного периода операций доступного Brokerage Account начать получение недостающего интервала без ожидания T-Bank в локальном ответе. Полностью загруженный прошлый интервал MUST NOT повторно сверяться из-за чтения страницы, часового обхода или удалённой команды ручной синхронизации. Изменяемый хвост операций MUST обновляться часовым обходом. Незавершённый интервал и ошибка загрузки MUST NOT обозначаться полными, а последний подтверждённый результат MUST оставаться доступным.
#### Scenario: Первый просмотр отсутствующего прошлого периода
- **WHEN** User открывает доступный исторический период без подтверждённой полноты
- **THEN** система возвращает локальный результат с явным состоянием неполноты и начинает загрузку недостающих данных
#### Scenario: Повторное открытие полного периода
- **WHEN** User повторно открывает уже полностью загруженный прошлый период
- **THEN** система читает сохранённые операции без повторной сверки с T-Bank
#### Scenario: Ошибка страницы загрузки
- **WHEN** загрузка части отсутствующего периода завершилась ошибкой
- **THEN** ранее подтверждённые операции сохраняются, а неполная часть не помечается как полная
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Upcoming events use complete source results
Система MUST возвращать только последний полностью успешно сформированный прогноз по доступному счёту. Если необходимые данные брокера или MOEX недоступны во время часового обновления, система MUST NOT заменять последний успешный прогноз частичным списком или изменять время его успеха. Инструменты, для которых источник не предоставляет поддерживаемые события, MUST пропускаться без синтетических строк «нет данных». Получение сохранённого прогноза MUST NOT запускать синхронизацию при открытии экрана; ошибку последней попытки MUST показывать отдельно.
#### Scenario: Недоступность источника
- **WHEN** необходимый источник данных брокера или MOEX временно недоступен при часовом обновлении
- **THEN** последний полный прогноз остаётся доступен с исходным временем, частичный список не публикуется, а ошибка отражается в статусе обхода
#### Scenario: Инструмент без поддерживаемых данных
- **WHEN** Portfolio содержит инструмент, для которого источник не предоставляет поддерживаемые будущие события
- **THEN** система не добавляет для него синтетическую строку в результат
#### Scenario: MOEX отвечает без запрошенного блока
- **WHEN** MOEX отвечает успешно, но не содержит запрошенного блока данных (например, дивидендов)
- **THEN** система трактует ответ как отсутствие данных по инструменту и не завершает обновление ошибкой
#### Scenario: Чтение при временном сбое обновления
- **WHEN** User открывает события доступного счёта после неудачного часового обновления и для счёта существует прежний полный прогноз
- **THEN** система возвращает прежний прогноз без ожидания источника и обозначает ошибку обновления отдельно
@@ -0,0 +1,84 @@
## MODIFIED Requirements
### Requirement: Состояния недоступности списка счетов
Система MUST читать подтверждённый доступный список счетов текущего подключения из последнего сохранённого результата и MUST NOT ожидать T-Bank или запускать проверку только из-за GET. При отсутствии T-Bank connection система MUST отвечать `409` Problem Details с code `TBANK_NOT_CONNECTED`. Если T-Bank уже отклонил сохранённый token как неаутентифицированный или без прав, система MUST отвечать `400` Problem Details с code `INVALID_TBANK_TOKEN` и сохранять connection, не выдавая личных данных. Временная ошибка часового обновления MUST оставлять последний подтверждённый список доступным с исходным временем и отдельным состоянием ошибки; при ещё неизвестном списке система MUST обозначать ожидание первой загрузки, не выдавая частичный список за полный.
#### Scenario: Подключение отсутствует
- **WHEN** User без сохранённого T-Bank connection запрашивает список
- **THEN** система не обращается к T-Bank и отвечает `409` Problem Details с code `TBANK_NOT_CONNECTED`
#### Scenario: Сохранённый token больше не действителен
- **WHEN** T-Bank отклоняет сохранённый token при часовом обновлении списка как неаутентифицированный или без прав
- **THEN** последующее чтение получает `400` Problem Details с code `INVALID_TBANK_TOKEN` и не раскрывает личные данные, но connection сохраняется
#### Scenario: T-Bank временно недоступен
- **WHEN** часовая проверка списка завершается временной либо иной upstream-ошибкой после ранее успешной проверки
- **THEN** обычное чтение возвращает последний подтверждённый список без ожидания T-Bank, сохраняя его исходное время и отдельную ошибку обновления
#### Scenario: Первый обход ещё не завершён
- **WHEN** User открывает список после подключения до получения первого подтверждённого списка
- **THEN** система не выдаёт частичный или неподтверждённый список за полный и сообщает о продолжающейся загрузке
### Requirement: Экран списка брокерских счетов
Клиент SHALL загружать список при открытии защищённого маршрута `/accounts` без ручной синхронизации и без автоматического refetch при возврате фокуса. Клиент MUST использовать успешный frontend-кэш списка в течение 60 секунд. После истечения этого срока клиент MAY перечитать локальный результат; пока экран открыт, он MUST перечитывать результат backend каждые 30 минут без запуска импорта. При уже имеющемся успешном списке он MUST сохранять последний успешный Client Cached Snapshot видимым во время фонового перечитывания и после его ошибки, ясно отделяя это состояние от успешного нового ответа. При успешном непустом ответе клиент MUST отобразить адаптивный одноколоночный список широких интерактивных карточек счетов с локализованными типом и, если доступна, датой открытия без времени; каждая карточка MUST ясно показывать тип, имя, финансовую сводку, вторичные метаданные и заметное действие открытия счёта, вести на `/accounts/:accountId` по всей области и сохранять доступное клавиатурное состояние. После успешной замены или удаления T-Bank connection клиент MUST инвалидировать список счетов.
Для доступной `portfolioSummary` карточка MUST показывать не более двух Money-итогов в исходных валютах и обозначать наличие остальных валют, если они есть; она MUST NOT показывать или подразумевать FX-конвертацию, межвалютный агрегированный итог либо Cash Balance как отдельный второй итог. При `portfolioSummary: null` карточка MUST показывать локальное безопасное состояние временной недоступности сводки, сохраняя метаданные счёта и возможность открыть его. При доступной пустой сводке карточка MUST не выдавать отсутствие итогов за временную ошибку.
#### Scenario: Пользователь открывает счёт из списка
- **WHEN** клиент успешно загрузил непустой список и User выбирает карточку счёта
- **THEN** клиент переходит на `/accounts/:accountId` с `id` выбранного счёта
#### Scenario: Список пуст
- **WHEN** клиент получает успешный пустой массив
- **THEN** клиент показывает штатное сообщение об отсутствии инвестиционных счетов T-Bank
#### Scenario: Список берётся из свежего frontend-кэша
- **WHEN** User повторно открывает список в течение 60 секунд после успешной загрузки
- **THEN** клиент показывает сохранённые счета без нового API-запроса
#### Scenario: Устаревший список загружается повторно
- **WHEN** User открывает список после истечения 60 секунд frontend-кэша и уже существует последний успешный список
- **THEN** клиент оставляет этот список видимым во время нового чтения сохранённого результата и ясно обозначает фоновое перечитывание без команды импорта
#### Scenario: Ошибка фонового обновления не скрывает snapshot
- **WHEN** перечитывание устаревшего списка завершается ошибкой при наличии последнего успешного Client Cached Snapshot
- **THEN** клиент сохраняет snapshot видимым, безопасно сообщает о неуспешном чтении и предоставляет повтор именно чтения
#### Scenario: Карточка ограничивает валютные итоги
- **WHEN** доступная сводка счёта содержит итоги в трёх или более валютах
- **THEN** карточка показывает не более двух исходных валютных итогов и обозначает наличие остальных без общего сконвертированного значения
#### Scenario: Недоступность сводки локальна карточке
- **WHEN** клиент получает список, в котором `portfolioSummary` одного счёта равна `null`
- **THEN** он показывает временную недоступность только на этой карточке, не скрывает остальные карточки и сохраняет переход на детали недоступной сводки счёта
#### Scenario: Пустая сводка не является ошибкой
- **WHEN** клиент получает доступную `portfolioSummary` с пустым `totalsByCurrency`
- **THEN** он показывает нейтральное состояние отсутствия финансовых итогов, а не состояние временной недоступности
### Requirement: Клиентские состояния ошибки списка счетов
Клиент MUST NOT автоматически повторять неуспешный запрос списка вне согласованного периодического перечитывания открытого экрана. При `TBANK_NOT_CONNECTED` клиент SHALL показать объяснение отсутствия подключения и действие «Открыть настройки T-Bank». При `INVALID_TBANK_TOKEN` клиент SHALL показать объяснение недействительного token и то же действие. При `TBANK_UNAVAILABLE` клиент SHALL показать безопасное объяснение временной недоступности, действие повторного HTTP-чтения и действие открытия настроек T-Bank. При иной ошибке клиент MUST показать общий безопасный error-state только с действием повторного чтения. Если последний успешный Client Cached Snapshot отсутствует, эти состояния MUST NOT показывать список; если snapshot существует во время фонового перечитывания, клиент MUST сохранить его видимым и показать соответствующее безопасное предупреждение и только применимые действия. Эти действия MUST NOT инициировать синхронизацию с T-Bank.
#### Scenario: Подключение требуется
- **WHEN** клиент получает `TBANK_NOT_CONNECTED`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Известная ошибка не повторяется автоматически
- **WHEN** запрос списка завершается известной ошибкой
- **THEN** клиент не выполняет немедленный повтор, пока User не выберет действие повторного чтения либо не наступит очередной 30-минутный срок открытого экрана
#### Scenario: Token требует внимания
- **WHEN** клиент получает `INVALID_TBANK_TOKEN`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Временная ошибка T-Bank
- **WHEN** клиент получает `TBANK_UNAVAILABLE`
- **THEN** клиент не показывает список и предлагает повторить локальное чтение либо открыть настройки T-Bank без команды импорта
#### Scenario: Неизвестная ошибка загрузки
- **WHEN** клиент получает ошибку без известного кода списка счетов
- **THEN** клиент не показывает список и предлагает только повторить чтение
#### Scenario: Известная ошибка при сохранённом snapshot
- **WHEN** фоновое перечитывание списка с последним успешным Client Cached Snapshot получает `TBANK_UNAVAILABLE`
- **THEN** клиент сохраняет snapshot видимым, сообщает о временной недоступности и предлагает повторить чтение либо открыть настройки T-Bank
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Client Cached Snapshot остаётся доступным при обновлении Accounts и Portfolio
Frontend MUST сохранять Client Cached Snapshot списка Brokerage Accounts или Portfolio на экране, если для этого же набора данных уже был успешно получен ответ. Начальная загрузка без такого ответа может показывать блокирующее состояние загрузки. Client Cached Snapshot MUST оставаться видимым во время перечитывания сохранённых данных и после его ошибки; он не является server-side fallback и MUST NOT изменять или интерпретировать поле `Portfolio.isStale`. Повтор после транспортной ошибки MUST повторять чтение, а не инициировать синхронизацию с T-Bank.
#### Scenario: Список Brokerage Accounts обновляется в фоне
- **WHEN** экран Accounts уже показывает Client Cached Snapshot и начинается фоновое перечитывание того же списка
- **THEN** frontend оставляет список видимым и показывает только неблокирующий индикатор обновления
#### Scenario: Обновление Portfolio завершается ошибкой после успешного ответа
- **WHEN** экран Brokerage Account уже показывает Client Cached Snapshot Portfolio, а последующее чтение этого же Portfolio завершается ошибкой
- **THEN** frontend сохраняет Portfolio видимым, показывает локализованное неблокирующее предупреждение о показе последних полученных данных и предоставляет повторную попытку чтения без команды синхронизации
#### Scenario: Server-side fallback и client cached snapshot различаются
- **WHEN** backend возвращает Portfolio с `isStale` либо перечитывание Client Cached Snapshot завершается ошибкой
- **THEN** frontend отображает соответствующее server-side либо client-side предупреждение независимо, не представляя один вид состояния другим
@@ -0,0 +1,50 @@
## Purpose
Задаёт независимое от открытых экранов часовое обновление инвестиционных данных пользователя и наблюдаемый результат последней попытки при частичных сбоях.
## ADDED Requirements
### Requirement: Часовой обход личных инвестиционных данных
Backend SHALL начинать первый обход после успешного подключения T-Bank и последующие обходы не ранее чем через 60 минут от начала предыдущей попытки для подключения. При старте после простоя он MUST учитывать время последней попытки, выполнять не более одного актуального обхода и MUST NOT воспроизводить пропущенные часы. Если попытки ещё не было и данных нет либо они старше часа, обход MUST начинаться при старте. Неуспешная попытка MUST NOT повторяться ранее следующего часового срока даже после перезапуска. Перекрывающиеся обходы MUST NOT выполняться; если срок настал во время ещё идущего обхода, соответствующий запуск пропускается.
Обход MUST проверять доступные счета и обновлять портфель каждого доступного счёта, изменяемый хвост операций и будущие события по бумагам текущего портфеля. Он MUST NOT повторно загружать уже полные прошлые интервалы операций, завершённые свечи или полную рыночную историю. Он MUST NOT зависеть от открытого frontend. Смена подключения MUST NOT позволять завершившейся поздно попытке старого подключения изменять личные данные или время их успеха.
#### Scenario: Первое подключение между часовыми запусками
- **WHEN** пользователь успешно подключает T-Bank после запуска backend
- **THEN** первый обход начинается без ожидания следующего календарного часа, а следующий допускается через час от начала первого
#### Scenario: Рестарт после неуспешной попытки
- **WHEN** backend перезапускается до истечения часа после неуспешного обхода с отсутствующими данными
- **THEN** обход не повторяется при рестарте, а последнее успешное состояние остаётся доступным согласно действующей границе доступа
#### Scenario: Перекрытый часовой срок
- **WHEN** начатый час назад обход всё ещё выполняется
- **THEN** второй обход не запускается, а пропущенная попытка не ставится в очередь сразу после завершения первой
#### Scenario: Возобновление после простоя
- **WHEN** backend не работал несколько часов и после старта обнаружил, что последняя попытка старше часа
- **THEN** он запускает только один текущий обход без синтетических прошлых обновлений
### Requirement: Сохранность и статус последней попытки
Успех одного счёта или источника MUST сохраняться независимо от ошибки другого; неполный либо неуспешный результат источника MUST NOT заменять его последнее успешное состояние или время. Backend SHALL предоставлять аутентифицированному пользователю статус только его последнего обхода: время начала, состояние (выполняется, полностью успешен, частично успешен или неуспешен) и проблемные счета и разделы без чувствительных сведений. Кабинет MUST показывать результат, время и проблемные счета/разделы при частичном успехе; отсутствие состоявшейся попытки MUST быть отличимо от успеха. Статус не предоставляет возможности начать синхронизацию с frontend.
#### Scenario: Ошибка одного счёта
- **WHEN** портфели двух счетов обновились, а третий не обновился
- **THEN** новые данные первых двух сохраняются, у третьего остаётся последнее успешное состояние, а статус обхода обозначает частичный успех и проблемный счёт
#### Scenario: Ошибка событий при успешных портфелях
- **WHEN** все портфели обновлены, но событие одной бумаги не обновилось
- **THEN** последний успешный прогноз сохраняется и статус обхода обозначает частичный успех с проблемным разделом
#### Scenario: Пользователь другого подключения запрашивает статус
- **WHEN** авторизованный пользователь запрашивает статус чужого обхода либо попытки старого подключения
- **THEN** система не раскрывает детали чужой или прежней границы доступа
@@ -0,0 +1,33 @@
## Purpose
Определяет получение справочных сведений и результатов поиска инструментов непосредственно от источника без самостоятельной долговременной копии, отдельно от личных данных и рыночной истории.
## ADDED Requirements
### Requirement: Справочные сведения и поиск запрашиваются онлайн
При чтении карточки акции или облигации и выполнении поиска система MUST получать справочные сведения инструмента, включая доступные название, тикер, ISIN, описание и показатели компании, онлайн, а не из отдельной долговременной сохранённой копии. Повторное чтение открытой страницы MUST запрашивать эти сведения снова. Недоступность источника MUST отображаться как ошибка соответствующего справочного блока, не скрывая доступные сохранённые позиции, операции, котировки, свечи и события. Подписи в последнем успешном снимке портфеля MAY использоваться как fallback для идентификации инструмента; они MUST NOT представляться как успешно полученные сейчас подробные сведения. Для расчёта будущих событий MAY запрашиваться необходимые справочные данные без их отдельного хранения.
#### Scenario: Повторное открытие карточки акции
- **WHEN** пользователь повторно открывает страницу акции после прежнего успешного чтения
- **THEN** справочные сведения запрашиваются онлайн заново, а сохранённая копия карточки не выдаётся за актуальный ответ
#### Scenario: Источник справочника недоступен
- **WHEN** подробные сведения акции получить не удалось, но доступен последний сохранённый снимок портфеля
- **THEN** справочный блок показывает локальную ошибку, а сохранённые позиции и их доступные подписи остаются видимы без обещания актуальности подробных сведений
### Requirement: Рыночные и событийные данные имеют отдельную политику
Система MUST продолжать сохранять котировки и запрошенные диапазоны свечей; при открытии страницы обновление текущей котировки и незавершённой свечи MUST выполняться не чаще одного раза в час. Будущие дивиденды, купоны и другие поддерживаемые события MUST сохраняться и обновляться в составе часового обхода бумаг портфеля. Полностью загруженные прошлые диапазоны свечей MUST NOT повторно загружаться автоматически. Получение этих данных MUST NOT запускать синхронизацию личных счетов при открытии страницы.
#### Scenario: Свежая котировка
- **WHEN** пользователь повторно открывает страницу инструмента менее чем через час после получения котировки
- **THEN** система использует сохранённую котировку без преждевременного повторного запроса цены
#### Scenario: Исторический диапазон свечей открыт впервые
- **WHEN** пользователь открывает диапазон свечей, которого ещё нет локально
- **THEN** система загружает его для просмотра, не начиная синхронизацию личного портфеля
@@ -0,0 +1,48 @@
## Purpose
Определяет накопление достоверных точек стоимости брокерского счёта по исходным валютам с сохранением истории между подключениями без раскрытия её вне подтверждённого доступа.
## ADDED Requirements
### Requirement: Полная оценка становится точкой истории
Система MUST добавлять точки на фактическое время каждого успешно полученного полного финансового снимка отдельного Brokerage Account, включая первую загрузку; стоимость MUST храниться раздельно по валютам без конвертации, полным JSON состава или подстановки искусственных точек за часы простоя. Если хотя бы у одной ненулевой позиции текущая стоимость не определена, точки MUST NOT записываться ни для одной валюты этого обновления, хотя новый состав счёта MAY оставаться текущим снимком. Пустой счёт без известной валюты MUST NOT создавать нулевую денежную точку. Ошибка получения обязательных данных MUST NOT менять предыдущие точки или последний успешный снимок. История MUST NOT иметь заранее установленного срока удаления.
#### Scenario: Полная оценка двух валют
- **WHEN** успешно получен полный снимок счёта со стоимостью в RUB и USD
- **THEN** появляются отдельные точки RUB и USD с фактическим временем получения, без общего конвертированного значения
#### Scenario: Неизвестная цена одной позиции
- **WHEN** получен новый состав счёта с оценёнными RUB-активами и позицией без известной цены
- **THEN** состав доступен как текущий снимок с неполной оценкой, но новые точки истории не появляются ни по одной валюте
#### Scenario: Пустой счёт без валюты
- **WHEN** успешно полученный счёт не имеет позиций, денег и известной валюты
- **THEN** история не получает точку с придуманной валютой или суммой
### Requirement: История переживает смену подключения без потери приватности
История MUST продолжаться для того же User и идентификатора Brokerage Account после замены токена или повторного подключения, если доступ к счёту подтверждён текущим подключением. При отключении либо после успешно полученного списка без ранее доступного счёта история MUST оставаться сохранённой, но помечаться удалённой и скрываться от чтения; при повторном подтверждении того же счёта пометка MUST сниматься. Ошибка получения списка MUST NOT менять видимость истории. Подтверждённый отказ токена MUST скрывать личные данные до восстановления доступа; временный сбой MUST сохранять видимым последний успешный результат при действующей границе доступа. История другого пользователя MUST NOT раскрываться.
#### Scenario: Замена токена с прежним счётом
- **WHEN** новый проверенный токен пользователя даёт доступ к прежнему идентификатору счёта
- **THEN** линия его истории продолжается, а поздняя работа старого токена не добавляет точек
#### Scenario: Счёт исчезает из подтверждённого списка
- **WHEN** успешно полученный полный список доступных счетов не содержит ранее доступный счёт
- **THEN** его история сохраняется, помечается удалённой и не раскрывается до повторного подтверждения доступа
#### Scenario: Временный сбой списка
- **WHEN** обновление списка счетов завершилось временной ошибкой
- **THEN** пометки удалённых счетов не меняются
#### Scenario: Отключение и повторное подключение
- **WHEN** пользователь отключил T-Bank, а позже подключил проверенный токен с тем же счётом
- **THEN** до подтверждения доступа история скрыта, после подтверждения она снова видима и пополняется без разрыва идентичности счёта
@@ -0,0 +1,70 @@
## MODIFIED Requirements
### Requirement: Свежесть и fallback полного Portfolio Snapshot
Система MUST хранить последний полный успешно полученный Portfolio Snapshot с принадлежностью User, экземпляру подключения и Brokerage Account. Обычный GET MUST читать сохранённые данные без ожидания T-Bank и MUST NOT запускать повторное обновление личного портфеля, даже если снимок отсутствует или старше часа; актуализацией владеет часовой обход. Portfolio source MUST считаться свежим в течение 60 минут после успешного получения. Ошибка обновления MUST сохранять прежний полный Snapshot и его время без лимита fallback. Если состав получен полностью, но отдельная позиция не имеет цены, система MUST сохранить новый состав с неполной оценкой; metadata MUST NOT определять успешность либо свежесть финансового снимка. Сохранённые подписи позиций MAY оставаться доступны при недоступности онлайн-справочника.
Система MUST делать прежние личные datasets недоступными после успешной замены или удаления подключения. Поздняя работа прежнего подключения MUST NOT публиковаться в новом. Самостоятельные справочные сведения и результаты lookup MUST запрашиваться онлайн, а не сохраняться в виде долговременного справочника; ошибка lookup не подтверждает отсутствие инструмента.
#### Scenario: Свежий Snapshot
- **WHEN** User повторно запрашивает тот же Portfolio в течение 60 минут после успешного получения
- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank
#### Scenario: Медленный lookup инструмента
- **WHEN** сведения об инструменте ещё запрашиваются онлайн
- **THEN** система не задерживает локальный ответ Portfolio ради lookup и показывает сохранённую в Snapshot доступную identity; подробные сведения остаются отдельным состоянием
#### Scenario: Fallback после временной upstream-ошибки
- **WHEN** обновление устаревшего Snapshot неуспешно из-за временной недоступности 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 с неизвестной стоимостью позиции, без подстановки нуля
### Requirement: Состояния и ошибки Portfolio на клиенте
Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд, без automatic retry или refetch при возврате фокуса. Открытый инвестиционный экран MUST перечитывать сохранённый результат каждые 30 минут без запуска синхронизации с T-Bank. Наблюдение известной активной работы MAY обновлять отображаемые ресурсы; чтение статуса и данных MUST NOT запускать импорт. Status сообщает состояние и ошибку без hash notifications и численных progress counters. После ухода со страницы наблюдение MUST прекращаться.
Предзагрузка всей истории свечей бумаг портфеля MUST NOT выполняться. Новый диапазон свечей загружается при его открытии; initial loading допустим. При загрузке без ранее полученного успешного ответа для этого же Brokerage Account клиент MUST показывать структурное skeleton-состояние ожидаемого содержимого, включая сводку, без вымышленных сумм. Область загрузки MUST иметь доступное обозначение занятости; skeleton MUST NOT быть интерактивным или получать клавиатурный фокус.
Истечение срока свежести MUST NOT само по себе скрывать уже доступный Client Cached Snapshot. Во время фонового обновления и после его ошибки клиент MUST сохранять ранее полученные данные того же Portfolio, включая валютные итоги и исходное `lastSuccessfulAt`. При обновлении клиент MUST показывать компактную неблокирующую индикацию без замены данных skeleton. При ошибке перечитывания он MUST показывать локализованное неблокирующее предупреждение о последних полученных данных и предоставлять действие повтора только HTTP-чтения. Сохранённые данные MUST NOT считаться новым успешным снимком или подтверждением текущей доступности Brokerage Account. Клиент MUST NOT предоставлять действие ручной синхронизации портфеля с T-Bank.
Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`, без нулевых валютных карточек. Для показываемого состава Portfolio клиент MUST сохранять время успешного получения снимка `lastSuccessfulAt`, форматируемое в UTC. При `isStale: true` клиент MUST показывать Snapshot, его исходное время и нейтральное предупреждение об устаревших данных. Это предупреждение само по себе MUST NOT добавлять действие ручного обновления. Server-side stale и ошибка перечитывания Client Cached Snapshot MUST оставаться раздельными состояниями: ошибка клиента MUST NOT менять или подменять `isStale`, а оба предупреждения и действие повтора чтения MUST оставаться доступными при одновременном наличии этих состояний.
При отсутствии ранее успешно полученного Client Cached Snapshot этого же Portfolio и ответе `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов без действия повтора. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора чтения разрешено только для временной и неизвестной ошибки.
#### Scenario: Показ устаревшего Portfolio
- **WHEN** клиент получает успешный ответ с `isStale: true`
- **THEN** клиент показывает позиции, остатки, валютные итоги, время последнего успешного снимка и нейтральное предупреждение; само это предупреждение не добавляет действие ручного обновления
#### Scenario: Недоступный счёт на клиенте
- **WHEN** для текущего Portfolio отсутствует ранее успешно полученный Client Cached Snapshot и клиент получает `404`
- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора
#### Scenario: Первичная загрузка Portfolio
- **WHEN** клиент ожидает Portfolio этого Brokerage Account без ранее полученного успешного ответа
- **THEN** он показывает структурный skeleton, включая место сводки, с доступным обозначением загрузки, без вымышленных или нулевых сумм и без фокусируемых placeholders
#### Scenario: Обновление после истечения срока свежести
- **WHEN** срок свежести показанного Portfolio истёк и начался часовой обход данных того же Brokerage Account
- **THEN** клиент сохраняет прежние валютные итоги и время их успешного получения, показывает неблокирующую индикацию обновления и не заменяет доступный состав и сводку skeleton-состоянием
#### Scenario: Ошибка обновления после успешного ответа
- **WHEN** клиент уже показывает Client Cached Snapshot Portfolio, а повторное HTTP-чтение этих же данных завершается ошибкой
- **THEN** клиент сохраняет данные, суммы и время прежнего снимка, показывает неблокирующее предупреждение и действие повтора чтения; ошибка не делает сохранённый снимок свежим и не изменяет `isStale`
#### Scenario: Ошибка клиентского обновления при server-side stale
- **WHEN** клиент показывает Portfolio с `isStale: true`, а фоновое перечитывание этого же Portfolio завершается ошибкой
- **THEN** прежние суммы и время остаются видимыми одновременно с нейтральным server-side предупреждением, отдельным предупреждением об ошибке клиентского перечитывания и его действием повтора чтения
#### Scenario: Повторное обновление успешно
- **WHEN** после ошибки HTTP-чтения User запускает повторное чтение и получает успешный Portfolio Snapshot
- **THEN** до ответа прежние суммы и время остаются видимыми, затем клиент показывает значения и `lastSuccessfulAt` успешного ответа, убирает предупреждение об ошибке клиентского чтения и показывает server-side предупреждение только в соответствии с новым `isStale`
#### Scenario: Штатный пустой Portfolio на клиенте
- **WHEN** успешно полученный Portfolio содержит пустые `positions` и `cashBalances`
- **THEN** клиент показывает существующее empty-состояние без валютных карточек с вымышленными нулевыми итогами
@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Создание и замена подключения T-Bank
Система SHALL позволять авторизованному User создать или заменить единственное подключение через `PUT /api/v1/tbank-connection` с token. Система MUST удалить внешние пробелы token, отклонить пустое значение как ошибку валидации и проверить token запросом T-Bank открытых счетов до сохранения. Пустой список счетов MUST считаться успешной проверкой. Успех MUST вернуть `200` с текущим статусом подключения; успешная замена MUST установить новый `connectedAt`. После успешного подключения MUST начинаться первая фоновая загрузка доступных личных данных без команды frontend. История стоимости прежних счетов MUST сохраняться, но показываться только для счетов, доступ к которым подтверждён новым подключением.
#### Scenario: Успешное создание подключения без счетов
- **WHEN** User отправляет непустой token и T-Bank успешно возвращает пустой список открытых счетов
- **THEN** система сохраняет подключение и отвечает `200` с `{ connected: true, connectedAt: <RFC 3339 UTC timestamp> }`; первая загрузка не создаёт фиктивные счета
#### Scenario: Успешная замена подключения
- **WHEN** User с подключением отправляет другой token и T-Bank успешно его проверяет
- **THEN** система заменяет token подключения, обновляет `connectedAt`, отвечает текущим статусом без token и начинает обновление подтверждённых счетов, не теряя их прежние исторические точки
#### Scenario: Невалидный token не заменяет существующее подключение
- **WHEN** T-Bank отклоняет token как неаутентифицированный или не имеющий прав
- **THEN** система отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN` и сохраняет прежнее подключение без изменений
#### Scenario: T-Bank временно недоступен при проверке token
- **WHEN** проверка token не завершается из-за временной или иной upstream-ошибки
- **THEN** система отвечает `503` Problem Details с code `TBANK_UNAVAILABLE` и не создаёт либо не изменяет подключение
### Requirement: Удаление подключения T-Bank
Система SHALL позволять авторизованному User удалить своё подключение через `DELETE /api/v1/tbank-connection`. Удаление MUST быть идемпотентным и отвечать `204`; после успешного удаления backend MUST прекратить использовать удалённый token и удалить относящиеся к User кэшированные текущие данные T-Bank. История стоимости счёта MUST сохраняться с пометкой удаления и MUST NOT раскрываться до подтверждения нового доступа к тому же счёту.
#### Scenario: Удаление существующего подключения
- **WHEN** User удаляет существующее подключение
- **THEN** система удаляет token и связанные кэшированные текущие данные User, скрывает сохранённую историю стоимости и отвечает `204`
#### Scenario: Повторное удаление подключения
- **WHEN** User удаляет уже отсутствующее подключение
- **THEN** система отвечает `204`, не раскрывая сохранённую историю
@@ -0,0 +1,68 @@
## 1. Данные и граница доступа
- [x] 1.1 [backend] Добавить совместимое хранение точек оценки и видимости истории отдельно от revision; сохранять по валютам только полную оценку в той же транзакции, что и успешно заявленный снимок, с дедупликацией попытки.
- **Covers:** `portfolio-value-history` — `Полная оценка становится точкой истории`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
- **Verification:** Изолированная SQLite с применёнными прежними migrations: новая миграция сохраняет старые данные; повторная/поздняя попытка не создаёт лишних точек, неизвестная цена не записывает ни одной валюты, неуспешная публикация сохраняет снимок и историю.
- [x] 1.2 [backend] Связать видимость истории с успешными PUT/DELETE подключения и публикацией полного membership; закрыть доступ старой revision и оставить историю для повторно подтверждённого accountId.
- **Covers:** `portfolio-value-history` — `История переживает смену подключения без потери приватности`; `tbank-connection` — `Создание и замена подключения T-Bank`; `tbank-connection` — `Удаление подключения T-Bank`
- **Verification:** SQLite/HTTP проверки смены токена, отключения, отсутствующего и вернувшегося счёта, ошибки membership, чужого User и поздней попытки; прежние текущие datasets не раскрываются и история не исчезает.
## 2. Часовой обход и локальные чтения
- [x] 2.1 [backend] Применить выбранный в Design Solution Assessment: один native timer и durable conditional reservation для запуска и восстановления часовой попытки без параллельного обхода.
- **Covers:** `hourly-investment-sync` — `Часовой обход личных инвестиционных данных`
- **Verification:** SQLite/integration сценарии первого подключения, рестарта до и после 60 минут, пропущенных часов, перекрытого тика, замены revision и перезапуска незавершённой работы; ни один сценарий не создаёт конкурирующих попыток.
- [x] 2.2 [backend] Оркестрировать подтверждённый membership, портфели счетов, изменяемый хвост операций и события через существующих владельцев источников; агрегировать результат последней попытки и открыть только read-only статус авторизованному User.
- **Covers:** `hourly-investment-sync` — `Часовой обход личных инвестиционных данных`; `hourly-investment-sync` — `Сохранность и статус последней попытки`; `brokerage-account-upcoming-events` — `Upcoming events use complete source results`
- **Verification:** Integration/HTTP проверки: ошибка одного счёта или события даёт частичный статус с безопасным разделом, успешные источники публикуются, старый прогноз и время переживают сбой; чужой User/старая revision не видны, OpenAPI contract parity проходит.
- [x] 2.3 [backend] Перевести обычные GET счетов, портфеля и прогноза на локальное чтение без запуска обновления личных источников; согласовать часовую свежесть, первый незавершённый обход и отдельно отображаемый сбой.
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `brokerage-accounts` — `Состояния недоступности списка счетов`; `brokerage-account-upcoming-events` — `Upcoming events use complete source results`
- **Verification:** HTTP/integration проверки локального ответа без RPC при missing/stale, неизвестного membership, временного сбоя, известного invalid token, неполной оценки и доступности последнего успешного снимка.
- [x] 2.4 [backend] Оставить для журнала запуск только первичной загрузки отсутствующих интервалов; обновление изменяемого хвоста перенести в часовой обход, не перечитывая полные прошлые интервалы.
- **Covers:** `brokerage-account-ledger` — `Отсутствующий исторический интервал загружается только впервые`; `hourly-investment-sync` — `Часовой обход личных инвестиционных данных`
- **Verification:** SQLite/integration проверка пустого полного периода, неполной страницы, повторного открытия прошлого месяца, движения конца текущего периода и неповторного чтения сохранённой истории.
## 3. Инструменты и граница публичного API
- [x] 3.1 [backend] Развязать сохранённые котировки, свечи и события от справочных payload; обновлять текущую котировку и изменяемую свечу не чаще часа, сохраняя покрытия завершённых диапазонов и полные прогнозы.
- **Covers:** `online-instrument-information` — `Рыночные и событийные данные имеют отдельную политику`; `brokerage-account-upcoming-events` — `Upcoming events use complete source results`
- **Verification:** Integration с изолированной SQLite: первая загрузка диапазона, отсутствие повторной загрузки полного прошлого диапазона, запрос свежей котировки, неудача онлайн-справочника и сохранность forecast/coverage/candles; миграция не удаляет связанные рыночные строки.
- [x] 3.2 [backend] Перевести detail, company, поиск и lookup инструментов на онлайн-ответ через существующие integration clients; отделить их ошибку от сохранённых подписей и финансовых ресурсов без самостоятельной долговременной копии.
- **Covers:** `online-instrument-information` — `Справочные сведения и поиск запрашиваются онлайн`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
- **Verification:** HTTP/service проверки повторного онлайн-запроса, совпадения UID и ticker, пустого/неоднозначного результата и upstream-сбоя при видимом сохранённом портфеле; не создаются новые долговременные detail/search/negative lookup.
- [x] 3.3 [backend] Сформировать публичный OpenAPI со статусом обхода, сохранив переходный контракт screen POST до готовности frontend.
- **Covers:** `hourly-investment-sync` — `Сохранность и статус последней попытки`; `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`
- **Verification:** `npm run openapi:generate`, `npm run test:openapi-contract` и configured HTTP suite для действующего и переходного wire contract; обычный GET не инициирует screen POST.
- [x] 3.4 [backend] Проверить рост SQLite на представительном объёме и фактическую частоту upstream-вызовов; сопоставить её с действующей первичной документацией T-Bank перед настройкой частоты, выполнить штатные backend gates и durability для новой схемы.
- **Covers:** `N/A` — операционная проверка выбранного в Design хранения и scheduler, без нового пользовательского поведения.
- **Verification:** Выполнено: SQLite 617,263,104 bytes на synthetic profile (3 accounts, 300,000 operations, 200 instruments, 1,000,000 candles), persisted-read phase сделал 0 upstream calls; `npx prisma validate`, durability с pre-history baseline, `npm run test:e2e` и performance budget проходят. Ограничение `TBANK_RATE_LIMIT_PER_SECOND` (default 5 per backend instance) зафиксировано в `docs/adr/020-hourly-sync-verification-evidence.md`; production quota не расширяется.
## 4. Клиентские экраны
- [x] 4.1 [frontend] Получить опубликованный backend OpenAPI artifact и обновить локальный snapshot/generated types для статуса без импорта backend internals.
- **Covers:** `hourly-investment-sync` — `Сохранность и статус последней попытки`
- **Verification:** `npm run openapi:generate` и `npm run openapi:check` из frontend на предоставленном artifact; статус типизирован, отсутствие попытки и изоляция ошибки не заменены вымышленным успехом.
- [x] 4.2 [frontend] Удалить инвестиционные кнопки/POST ручной синхронизации и source-recovery triggers; сохранить транспортный retry только как повтор чтения, а открытые инвестиционные экраны перечитывать раз в 30 минут.
- **Covers:** `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`; `brokerage-accounts` — `Экран списка брокерских счетов`; `brokerage-accounts` — `Клиентские состояния ошибки списка счетов`; `frontend-data-resilience` — `Client Cached Snapshot остаётся доступным при обновлении Accounts и Portfolio`
- **Verification:** UI/query проверки 30-минутного активного экрана, прекращения опроса после ухода, сохранения снимка при сбое и повторного HTTP GET без POST; auth-refresh и действия ручных карт не меняются.
- [x] 4.3 [frontend] Показать в кабинете время/исход последнего обхода и названия проблемных счетов и разделов; справочный блок карточки отделить от позиций, событий, свечей и локальной ошибки чтения.
- **Covers:** `hourly-investment-sync` — `Сохранность и статус последней попытки`; `online-instrument-information` — `Справочные сведения и поиск запрашиваются онлайн`; `brokerage-accounts` — `Экран списка брокерских счетов`
- **Verification:** Проверки состояний `none/running/succeeded/partial/failed`, локального сбоя detail при видимых финансовых данных, доступных текстовых подписей и переноса статуса на узком экране согласно inline UI package Design revision 1.
- [x] 4.4 [frontend] Завершить клиентский переход штатными gates и сверкой критического маршрута нового подключения → первый обход → статус → сохранённый портфель на открытом экране.
- **Covers:** `N/A` — интеграционная проверка принятого UI package и публичного контракта после изменения нескольких экранов, без отдельного нового требования.
- **Verification:** `npm run verify` в frontend и наблюдаемые состояния критического маршрута с предоставленным backend OpenAPI.
## 5. Завершение перехода
- [x] 5.1 [backend] После перехода frontend убрать неиспользуемый screen POST и наблюдение прежней работы без затрагивания нового статуса обхода.
- **Covers:** `hourly-investment-sync` — `Сохранность и статус последней попытки`; `portfolio-view` — `Состояния и ошибки Portfolio на клиенте`
- **Verification:** `npm run openapi:generate`, `npm run test:openapi-contract` и configured HTTP suite: старый trigger недоступен, обычный GET и повтор чтения не запускают импорт.
## 6. Нормативная и as-built документация
- [x] 6.1 [specs] Проверить полное соответствие девяти Delta Specs одобренному Proposal и Design, сохранить точные имена Requirements и валидировать change и основные specs.
- **Covers:** `N/A` — самостоятельная проверка нормативного артефакта и согласованной границы изменения, без добавления нового поведения.
- **Verification:** `openspec schema validate wealth-ledger-sdd --verbose`, `openspec validate hourly-portfolio-sync-history --type change --strict --store wealth-ledger-specs`, `openspec validate --specs --strict --store wealth-ledger-specs`, `npm run verify` в specs.
- [x] 6.2 [docs] После проверки реализованного поведения дополнить глоссарий и объяснение свежести/истории, обновить соответствующие страницы инвестиций и зафиксировать новый ADR, который связывает изменённое решение с ADR 017, не переписывая исторический контекст.
- **Covers:** `N/A` — as-built объяснение подтверждённых решений Design в отдельном docs owner, не параллельная норма требований.
- **Verification:** Проверить маршруты чтения, локальные ссылки и смысл финансовых значений; `npm run verify` из docs.
@@ -0,0 +1,2 @@
schema: wealth-ledger-sdd-v2
created: 2026-10-09
@@ -0,0 +1,57 @@
# Completion Evidence: reconcile-hourly-investment-sync
## Outcome
The approved reconciliation was implemented across backend, frontend and docs.
The hourly walk is the normal personal-data update owner; ordinary resource GETs
and retries remain local/read-only, with the approved first-load exceptions.
Resource metadata now distinguishes confirmed data state from hourly attempt state
and browser transport state.
## Delivery Map
| Area | Result | Commit |
| --- | --- | --- |
| backend | Resource envelope, hourly attempt projections, saved forecast window semantics, read-only events GET, candle read boundary and generated OpenAPI | `aee330c` |
| frontend | Generated contract consumer, Accounts/Portfolio/operations/events status presentation, HTTP-only retry, accessible status text and snapshot retention | `6b1b26a` |
| docs | Six approved AS-IS documentation and ADR updates | `a45d3e7` |
| specs | Approved v2 planning package; native sync/archive remains finalization work | pending archive commit |
| main | N/A: no workspace/config/Compose orchestration change | N/A |
| qa | N/A: separate QA repository does not exist | N/A |
## Verification
| Repository | Verification | Result |
| --- | --- | --- |
| backend | `npm run typecheck:app` | PASS |
| backend | `npm run typecheck:test` | PASS |
| backend | `npm test -- --runInBand` | PASS: 37 suites, 229 tests |
| backend | `npm run test:sqlite -- upcoming-events.e2e-spec.ts` | PASS: 22/22 |
| backend | `npm run openapi:generate` | PASS |
| backend | `npm run test:openapi-contract` | PASS: 3 tests |
| frontend | `npm run verify` | PASS: 65 files, 436 tests, typecheck, lint, OpenAPI, dependency gates, app and Storybook builds |
| docs | `npm run verify` | PASS |
| specs | `openspec validate reconcile-hourly-investment-sync --type change --strict --store wealth-ledger-specs` | PASS |
| specs | `openspec validate --specs --strict --store wealth-ledger-specs` | PASS: 26 specs, 0 failed |
| all | `git diff --check` | PASS |
The backend repository-wide lint command reports existing unused ESLint
suppressions. Changed-file lint with `--pass-on-unpruned-suppressions` passed;
all application/test/type/OpenAPI gates above passed. This repository hygiene
issue is not introduced by the reconciliation implementation.
## Independent Validation
Independent review found no remaining Critical or Important behavioral findings
after fixing ordinary events GET locality, saved forecast window/calculatedAt,
candle reread behavior and Accounts metadata presentation. The reviewed scope
covered approved Proposal, ten Delta capabilities, System Analysis, Design, QA
Analysis, Docs Impact, Tasks, current backend/frontend/docs diffs and generated
OpenAPI.
## Approval Binding
Completion uses the current approved planning artifacts in this change directory.
Finalization must re-run native sync/archive checks against the current files and
must not treat artifact existence as approval. Legacy active changes remain on
their legacy schema and are not implicitly migrated by this archive.
@@ -0,0 +1,358 @@
# Engineering Design
## Context and Constraints
The deployed backend is a single NestJS process with SQLite/Prisma. Its durable
hourly walk, connection revision publication fence, saved investment resources,
and frontend 30-minute rereads already provide most of the approved model.
The remaining mismatch is at the resource boundary: backend
`InvestmentResourceMetaDto` and `investmentResourceMeta` expose legacy
`missing|partial|complete` availability and `idle|running|failed` work state,
while the frontend projects that legacy metadata through `useResourceQuery` and
uses `sync=observe` as a transport mode. The generated backend `openapi.json`
and frontend snapshot agree with each other, but they describe this AS-IS
contract rather than the approved target.
The backend-generated OpenAPI remains the executable contract. The change-local
`system-analysis/contracts/investment-resource-envelope.schema.json` is a
semantic target and must not become a second OpenAPI source, generated client,
or runtime validator. Route identities, authentication/access boundaries,
Problem Details codes, and resource payload DTOs remain established unless an
approved target requirement explicitly changes them.
No normal personal resource GET may wait for or call T-Bank. The only
acquisition exceptions remain the first unconfirmed ledger interval and first
unconfirmed candle range. The design preserves the single-process boundary; it
does not add distributed scheduling, a job service, polling endpoint, manual
sync command, public value-history read, or a new UI package dependency.
## Technical Goals / Non-Goals
**Goals**
- Publish one generated HTTP envelope for Accounts, Portfolio, operations, and
events: `{ data, meta }`, with Accounts-only top-level `sync`.
- Make resource-result state and hourly-attempt state independently truthful:
confirmed data stays available after a failed/partial attempt, while an
initial result remains `null` until complete.
- Preserve local-read, connection-revision, and first-load invariants while
migrating Swagger DTOs, mapper inputs, runtime responses, and frontend
generated types together.
- Keep the existing successful TanStack Query snapshot during read retries and
retain the existing 30-minute open-screen reread cadence as a local HTTP
reread, not an import trigger.
**Non-Goals**
- Rebuild source owners, hourly scheduling, portfolio-value storage, or the
legacy status endpoint beyond removing their use as a source-work command
from these screens.
- Introduce a compatibility envelope, client-side DTO normalizer, duplicate
cache, new route/version, manual refresh/import action, or a copy of OpenAPI
in specs.
- Plan documentation edits, implementation tasks, or product behavior beyond
the approved proposal, delta specs, and System Analysis.
## Implementation Impact Map
### frontend
`affected` - generated transport types, `shared/api/resource-query.ts`, the
existing brokerage-account/portfolio/operations/events query owners, and their
page VMs/views consume the target metadata. `useScreenSync` remains the common
30-minute open-screen reread owner, but its source-error derivation must read
`meta.lastAttempt` rather than legacy `meta.sync`; retry stays HTTP-only.
### backend
`affected` - investment resource DTOs, mapper, endpoint response DTOs,
resource readers, connection/hourly-attempt projections, Swagger annotations,
and generated `openapi.json` implement the semantic envelope. Existing durable
attempt, revision, membership, snapshot, forecast, coverage, and history
owners remain the data owners.
### specs
`affected` - this Design makes the approved deltas and System Analysis
implementable without duplicating the executable contract.
### docs
`affected` - durable AS-IS references are limited to ADR 019, ADR 017,
`architecture/cross-cutting/data-freshness.md`,
`domains/portfolio/flows/portfolio-loading.md`, and
`domains/income/upcoming-events.md`. Their impact is recorded here only;
documentation sequencing is not designed in this artifact.
### qa
`N/A` - no separate QA/E2E repository exists.
## Decisions
### Backend: one resource-meta projection and one connection-walk projection
Replace the legacy shared metadata vocabulary at the existing
`InvestmentResourceMetaDto`/`investmentResourceMeta` seam. Every affected
resource envelope has required `meta.freshness`, `meta.completeness`,
`meta.lastSuccessfulAt`, `meta.lastAttempt`, and `meta.availability`.
`lastAttempt` is a resource-scoped projection with state
`none|running|succeeded|partial|failed`, nullable start/end times, and only
safe failed section/account/error-code entries. `none` is a concrete status,
not an omitted field or success.
The mapper derives freshness and successful time only from the returned
confirmed resource result. A later running, partial, or failed walk neither
changes that time nor makes data fresh. `data: null` pairs with `unknown`/
incomplete and either `initial-loading`, `temporarily-unavailable`, or
`connection-unavailable`; a confirmed empty collection pairs with available
and complete. The ledger's permitted first-load rows retain incomplete
coverage and are never represented as a confirmed complete result.
Accounts additionally projects the connection-wide walk as `sync: null` when
no attempt exists, otherwise the same complete safe attempt shape. Other
resource envelopes do not gain top-level `sync`. This preserves the single
shared metadata owner while avoiding the current ambiguity between per-resource
`meta.sync` and Accounts-level walk status.
The existing `resourceKey`, `dataRevision`, `coverage`, and dependency details
remain internal projection inputs or endpoint-specific metadata only where
their approved behavior still requires publication; they must not substitute
for any of the five required target meanings. No client must infer target state
from their presence.
### Backend: retain storage owners and publication fences
The durable hourly attempt remains the sole normal publisher. Resource readers
read the saved current connection revision and project its persisted result;
they do not reserve work or contact upstream. Each successful section can
persist independently, preserving the previous confirmed resource result and
its success time when a later section fails. Forecast publication remains
complete-source-only, and exact per-currency value-history writes remain in the
same successful Portfolio publication transaction.
Connection PUT establishes a new revision before its first walk; DELETE and
replacement prevent old personal snapshots and old attempt status from being
read or published. Invalid-token knowledge blocks disclosure rather than being
masked by saved data. Data and storage changes must be additive when a new
persisted distinction is necessary; existing snapshots, coverage, forecasts,
attempts, revisions, and retained value history are not reset or reclassified
by a destructive migration.
### Contract: Swagger is produced at the DTO boundary
Swagger DTO annotations and endpoint response classes are the only place the
wire schema is authored. The backend regenerates `openapi.json` from the
implemented DTOs, then the frontend receives that published snapshot and runs
its existing `openapi-typescript` generation. Hand-editing frontend
`schema.d.ts`, adding handwritten parallel interfaces, or retaining a
compatibility mapper would hide drift and is excluded.
The resulting generated contract must express nullable `data`, all required
`meta` fields, nullable attempt timestamps, nullable problem fields, Accounts
top-level nullable `sync`, and absence of top-level `sync` on the other
resource responses. It must retain `sync=observe` only as a read-observation
compatibility parameter while it exists in the generated route contract; it
cannot cause source work and is not used to represent the new hourly state.
The generated contract reconciles field spelling with the target schema before
release, not through a second contract artifact.
### Frontend: one query cache and metadata-aware screen state
`useResourceQuery` remains the sole adapter that projects resource envelopes
into `data`, `meta`, and Accounts `sync`, preserving its existing query key,
authenticated transport, access-group invalidation, and Client Cached Snapshot
behavior. It must project the generated target types directly. Resource query
owners remain at their existing entity slices; page VMs, not presentation
views, choose initial loading, unavailable/error, empty, and loaded states.
`useScreenSync` remains the single 30-minute timer for open investment
screens. It calls ordinary local `refetch()` and its retry path continues to
use the existing observation read without changing cache identity. It derives
server attempt warning state from `meta.lastAttempt`, keeps transport failures
separate, and never starts personal synchronization. Existing Accounts
30-minute `staleTime` remains aligned with this cadence; Portfolio and other
screen resources keep their current short cache freshness where already
established while the open-screen timer supplies the approved reread rhythm.
All affected account screens render the five metadata meanings in text or an
element with an accessible name, not color alone. A complete empty resource is
normal empty content. `data: null` renders initial-loading or unavailable
states without invented values. A failed/partial hourly attempt is an
independent nonblocking warning beside confirmed data and its original time.
`Portfolio.isStale` remains snapshot-specific and is shown separately from
hourly-attempt and client HTTP-error warnings. A client snapshot stays visible
during and after a failed background HTTP reread; connection replacement or
deletion still invalidates personal queries.
## Cross-cutting Implementation
The canonical state split is:
| Concern | Owner | Observable representation |
| --- | --- | --- |
| Returned confirmed resource result | Saved resource owner | `data`, freshness, completeness, `lastSuccessfulAt`, availability |
| Resource-relevant hourly outcome | Durable walk and resource projection | `meta.lastAttempt` |
| Connection-wide hourly outcome | Durable walk | Accounts `sync` only |
| In-flight/failed browser reread | TanStack Query | Existing query lifecycle and local retry warning |
| Snapshot-specific Portfolio source age | Portfolio owner | `Portfolio.isStale` |
This split prevents a source failure from becoming a synchronous GET failure,
prevents the UI from treating its cache as server fallback, and preserves safe
access invalidation. Safe attempt problems contain only the approved section,
optional account ID, and safe error code: no token, upstream payload, cursor,
connection revision, or raw exception text is serialized or logged as status
metadata.
Events retains its existing account and `all` scopes and its saved forecast
window/filter semantics. Its metadata independently reports the latest events
attempt, so a saved forecast can remain available when a later refresh fails.
Operations retains its coverage semantics; only its explicitly approved missing
historical first-load acquisition may work outside the hourly walk.
## Data / Storage Changes
No new storage architecture is needed. Existing durable tables/records for the
current connection revision, hourly attempts, snapshots, operation coverage,
forecast source windows, and retained portfolio-value points are authoritative.
If implementation needs persisted terminal-state or problem-detail values not
already durable, it extends the existing attempt record/schema additively and
backfills conservatively: absent historical attempt information is projected as
`none`, never as `succeeded`.
Migrations must preserve all current user, connection, personal-resource,
coverage, forecast, and history rows. They must not reset SQLite, rewrite an
old partial result as complete, expose retained history across a new connection
boundary, or delete old data merely to fit the new vocabulary. Compatibility is
achieved by deploying the backend DTO/runtime and its generated OpenAPI as one
server contract, followed by the frontend snapshot/types and rendering change;
there is no claim that an old frontend understands the target envelope.
## Contract Implementation
The implementation contract is the generated result of the following mapping:
| Target concept | Backend source | HTTP location |
| --- | --- | --- |
| Last confirmed result or no complete result | Existing local resource reader | `data` |
| Result freshness and completeness | Resource mapper from confirmed result/coverage | `meta.freshness`, `meta.completeness` |
| Original confirmed-result time | Saved source timestamp | `meta.lastSuccessfulAt` |
| Relevant hourly result | Durable attempt plus resource section projection | `meta.lastAttempt` |
| Whether complete data can be used | Connection/access/read result | `meta.availability` |
| Complete current-connection walk | Durable connection-wide attempt | Accounts `sync` |
The target schema's `problemSection` shape is used for both scopes. For a
connection-wide Accounts walk, its list may include all affected sections; for
a resource-level attempt, it is restricted to failures relevant to that
resource. A running attempt has `finishedAt: null`; terminal attempts have a
completion time; `none` has null timestamps and no problems. These invariants
are validated at runtime tests and in the generated OpenAPI, rather than by
shipping the JSON Schema to application consumers.
## Developer Verification Strategy
Backend verification proves the contract at DTO, mapper, controller, and
SQLite boundaries: successful empty data; first load with `data: null`; stale
fallback after a failed walk; partial walk with preserved data; resource versus
Accounts attempt scoping; events forecast-window filtering; invalid-token and
connection-revision disclosure fences; no upstream personal call on ordinary
GET; and both first-load exceptions staying narrow. It regenerates OpenAPI and
runs the existing OpenAPI contract, type, lint, and relevant E2E suites.
Frontend verification updates the supplied snapshot, runs `openapi:check`, and
uses MSW/page-VM tests to prove generated metadata projection, no automatic
retry, HTTP-only retry, 30-minute open-screen reread, cache retention after a
background error, connection invalidation, and distinct server stale/hourly
attempt/client transport states. UI checks cover keyboard access, accessible
status text, loading/error/empty/loaded states, and narrow viewport wrapping.
Before release, verify the backend-generated OpenAPI and frontend snapshot are
the same published artifact, then run each repository's configured verification
commands. In this specs store, run both v2 schema validation and strict change
validation; structural success is supplemented by a semantic comparison against
the approved deltas and System Analysis.
## UI/UX Package
**Applicability and scope:** `new/changed`. This is an inline package for the
existing Accounts, account Portfolio, operations, and events screens. It adds
the approved status vocabulary and preserves 30-minute local rereads; it does
not introduce new composition, graphics, navigation, or controls.
**Package revision:** `1` (this Design). No visual files, external references,
**Screens, states and interactions:**
| Screen/state | Presentation | Interaction |
| --- | --- | --- |
| No complete result, initial attempt running | Existing structural loading state, labelled as loading | No manual sync action |
| No complete result, attempt failed/unavailable | Safe unavailable/error state with target status text | Retry rereads HTTP only when recovery allows |
| Complete empty result | Normal domain empty state and target status text | Existing navigation/actions only |
| Complete data with running/succeeded/partial/failed attempt | Content and original success time remain visible; attempt appears as separate accessible status | No source-work action |
| Client Cached Snapshot during/after HTTP failure | Existing content remains visible; transport warning remains distinct from server state | Existing retry rereads HTTP only |
**Responsive behavior:** reuse existing Mantine/theme components and page
layouts. Status text wraps without hiding account content or money values on
narrow screens; color supplements an accessible label. Existing skeletons
remain noninteractive and unfocusable. Existing cards, buttons, and icons are
used through their public package exports; no wrapper, SVG asset, token, or
component package revision is introduced.
**Reused source and differences:** `N/A` for approved visual reuse. This is a
new inline status package revision `1`, not an immutable reuse of a prior
approved visual package. Existing cards, layouts, hooks, and UI packages are
implementation inputs only; they are not the approved visual source. The
package adds semantic presentation of the five target metadata fields and
separate hourly-attempt/client-transport warnings. No local visual export is
required.
## Risks / Trade-offs
- Envelope evolution is a coordinated backend/frontend release risk. Generated
artifacts prevent hidden type drift but cannot make an older client interpret
new enum meanings.
- Mapping resource relevance from connection-wide attempts can over-report or
under-report a failure if it is not constrained by resource/section scope;
mapper tests must cover each affected resource.
- A durable `none` projection for old rows is intentionally less informative
than inventing a historic success; it preserves truthfulness.
- The 30-minute client timer can issue local reads while an hourly walk runs;
this is acceptable because reads do not contend for upstream work and retain
the last confirmed result.
- Single-process scheduling remains unsuitable for multi-process deployment;
adding leases, a worker, or distributed coordination requires a separate
decision.
- Documentation currently contains legacy request-driven/manual-refresh
descriptions. Until the identified durable references are updated, they must
not be used as implementation authority over the approved behavior.
## Migration / Rollout / Rollback
Rollout is backend-first at the contract source: apply only additive persistence
changes if required, implement and test runtime projection/fences, regenerate
and publish backend OpenAPI, then update the frontend snapshot/generated types
and renderers. Do not enable the new projection alongside a second personal
write owner. A frontend built from the old snapshot is not the compatibility
target; rollout uses the matched generated artifact pair.
| Field | Decision |
| --- | --- |
| Status | `required` |
| Trigger | Personal-data disclosure across connection revisions, false complete/fresh state, malformed generated contract, failure to preserve confirmed fallback, or a frontend unable to read the published contract. |
| Actions | Stop the affected rollout, preserve a SQLite copy, return to the last compatible backend/frontend pair, and fix forward. Do not restore screen-triggered/manual synchronization while the hourly owner remains enabled. |
| Data safety | Never reset production SQLite, delete additive attempt metadata, delete snapshots/coverage/forecasts/value history, or use a destructive downgrade. Old rows without target details safely project `none`. |
| Verification | On a representative copy, verify migration integrity, revision/access fences, ordinary local GET behavior, preserved fallback timestamps, generated OpenAPI/snapshot identity, and frontend rendering before traffic resumes. |
## Solution Assessment
| Option | Assessment |
| --- | --- |
| Evolve the existing shared DTO/mapper and generated OpenAPI | Chosen. It is the narrowest path: one resource-meta projection already owns all affected envelope metadata, Swagger already produces the contract, and frontend types already derive from the supplied snapshot. |
| Add a target adapter/parallel endpoint or versioned envelope | Rejected. It duplicates mapping and cache paths, delays removal of ambiguous states, and creates an unnecessary second compatibility surface. |
| Handwrite frontend target types or runtime validation | Rejected. Generated `paths`/schemas already own transport typing; manual copies would drift and a validator adds no trust boundary. |
| Add a scheduler/queue/cache service to express status | Rejected. Durable single-process attempts and SQLite records already express the required state. A new service cannot improve this contract without changing the explicitly retained deployment model. |
| Add a new design-system package/component family | Rejected. Existing Mantine and `@wealthledger/ui` states cover the textual, accessible status presentation; a new package would be abstraction without a new visual primitive. |
The chosen solution is intentionally a contract-and-projection reconciliation,
not a new synchronization system. It minimizes code and migration surface while
making the approved observable model expressible through the existing owners.
@@ -0,0 +1,50 @@
# Documentation Impact
## Summary
Durable AS-IS documentation requires updates. The current pages still describe
request-driven freshness, screen-started work, and legacy `sync` metadata;
after delivery they must describe the hourly owner, local resource reads, and
the independent resource-result and hourly-attempt state.
## Documentation Changes
| Action | Target | Category | Resulting AS-IS | Source | Verification |
|---|---|---|---|---|---|
| update | `architecture/cross-cutting/data-freshness.md` | cross-cutting | Ordinary personal-resource GETs and HTTP retries read saved local results and never start personal T-Bank work. The hourly walk is the normal update owner; only an unconfirmed historical ledger interval and an unopened candle range may acquire their own missing result. Document the independent `data` freshness, completeness, `lastSuccessfulAt`, availability, and resource-relevant last hourly attempt; retained complete data remains available after a later failed or partial attempt, while no complete result is `data: null`. Keep Client Cached Snapshot, `Portfolio.isStale`, and failed browser rereads distinct from server result and hourly-attempt state. | `tbank-data-sync` — `Часовой обход владеет обновлением личных данных T-Bank`; `tbank-data-sync` — `Результат личного обновления остаётся наблюдаемым`; System Analysis §§ Interaction Flows, Data and State Semantics, Error and Recovery Semantics, Observability Semantics; Design § Cross-cutting Implementation. | Compare the page with delivered resource responses and UI: normal GET/retry has no upstream personal call; documented state meanings match generated OpenAPI/runtime responses; stale fallback, no-result state, client read failure, and hourly failure remain separately explained. |
| update | `domains/portfolio/flows/portfolio-loading.md` | domain | Portfolio loading reads the saved current-connection snapshot through a read-only envelope. The hourly walk refreshes Portfolio independently of the screen; the open screen's 30-minute reread and retry are local HTTP reads only. The page explains `data: null` before a complete snapshot, preserved snapshot/time after a failed or partial walk, separate resource metadata and `Portfolio.isStale`, and current-connection/revision disclosure fences without a manual refresh or status-observer flow. | `portfolio-view` — `Получение Portfolio выбранного Brokerage Account`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `frontend-data-resilience` — `Client Cached Snapshot остаётся доступным при обновлении Accounts и Portfolio`; System Analysis §§ Contracts, Data and State Semantics, Error and Recovery Semantics; Design § Frontend: one query cache and metadata-aware screen state. | Read the delivered Portfolio route and screen against the page: GET and retry do not call T-Bank or expose a manual sync action; snapshot fallback retains its original time; no-data, empty, stale, failed hourly attempt, and failed HTTP reread are not conflated. |
| update | `domains/income/upcoming-events.md` | domain | Upcoming events reads the saved forecast for account or `all` scope without starting personal sync. It documents strict inclusive UTC request dates, defaults, filtering to the intersection with the forecast's original inclusive window, and retention of that forecast's `calculatedAt` and source window. A later failed hourly events update leaves the last complete forecast readable and reports its failure independently; absent complete forecast is `data: null`, while an empty intersection is a successful empty result. | `brokerage-account-upcoming-events` — `User can retrieve upcoming events for an account`; `brokerage-account-upcoming-events` — `Upcoming events use complete source results`; System Analysis § Contracts; System Analysis §§ Data and State Semantics, Error and Recovery Semantics; QA Analysis § Coverage Matrix: Events query and saved forecast. | Compare account and `all` responses/screens with the page: invalid range returns `INVALID_EVENTS_PERIOD` without source work; window intersection preserves original forecast metadata; empty intersection and failed-refresh fallback are correctly distinguished. |
| update | `architecture/http-api.md` | architecture | The investment API section describes the generated backend OpenAPI as the executable contract for `{ data, meta }` resource responses: nullable `data`; required independent resource-result meanings; resource-scoped hourly `lastAttempt`; and Accounts-only connection-wide `sync`. It states that legacy `sync=observe` may remain a read-observation compatibility parameter but cannot start source work, removes the request-started screen-sync command/status model as current AS-IS, and retains established route identities and Problem Details behavior. | System Analysis §§ Contracts, Compatibility / Migration; `tbank-data-sync` — `Результат личного обновления остаётся наблюдаемым`; `hourly-investment-sync` — `Последний результат обхода доступен только для наблюдения`; Design § Contract: Swagger is produced at the DTO boundary; QA Analysis §§ Cross-system Verification, Regression Impact. | Compare prose and examples with the backend-generated OpenAPI and runtime responses: affected envelopes expose target nullability/enums and required metadata, only Accounts has top-level `sync`, and ordinary reads cannot trigger personal work. |
| update | `adr/017-local-tbank-reads-and-background-sync.md` | ADR | Preserve ADR 017's historical decision and rationale, but mark its request-driven, manual-refresh, and no-periodic-walk decisions superseded by ADR 019. Retain its still-valid local-storage, completeness, and access-boundary context, with a link to current freshness documentation. | System Analysis § Durable Documentation Candidates; Proposal § Why; Design § Risks / Trade-offs. | ADR 017 explicitly identifies superseded decisions and links to ADR 019; it no longer presents a manual/request-driven sync model as current architecture. |
| update | `adr/019-hourly-reads-and-online-instrument-reference.md` | ADR | Clarify the accepted hourly architecture: the last walk is scoped to the current connection; ordinary GET/retry does not acquire personal data; only the first unconfirmed ledger interval and first unopened candle range are narrow acquisition exceptions. Clarify that resource-result state and hourly-attempt state remain independent and that observation is not a command. | System Analysis §§ Interaction Flows, Contracts, Observability Semantics; `tbank-data-sync` — `Часовой обход владеет обновлением личных данных T-Bank`; `hourly-investment-sync` — `Последний результат обхода доступен только для наблюдения`; Design § Cross-cutting Implementation. | ADR 019 describes only the delivered hourly model and its bounded exceptions; it agrees with the updated freshness page and does not introduce a second HTTP contract source. |
## Diagram Promotion
None. `system-analysis/sequences/hourly-investment-walk.mmd` and
`system-analysis/sequences/read-only-resource-get.mmd` are approval-time
interaction evidence. Their durable operational meaning is covered by the
updated freshness, portfolio-loading, events, and HTTP API pages; promoting
them would duplicate those explanations without adding a durable navigation or
architecture view.
## ADR Decision
N/A. No new architectural decision is required: ADR 019 already accepts the
durable hourly-read architecture and is clarified above. ADR 017 is an
historical decision whose conflicting request-driven/manual-refresh portions
must be explicitly superseded, not rewritten into a new decision.
## Glossary / Navigation Impact
N/A. The change refines existing concepts (freshness, completeness, Client
Cached Snapshot, Portfolio snapshot, and T-Bank connection) rather than adding
a durable term needing a glossary definition or a new navigation entry.
## No-Change Rationale
No new documentation page, diagram, or machine-readable HTTP artifact is
promoted. Backend-generated OpenAPI remains the executable HTTP contract; the
change-local schema and sequences remain archived planning evidence. No docs
entry is planned for private retained portfolio-value history because this
change does not introduce a public history read, and its access boundary is
already covered by the connection/freshness documentation.
@@ -0,0 +1,94 @@
# Proposal
## Why
Два завершённых legacy changes описывают несовместимые модели инвестиционной
синхронизации. `persist-tbank-data` закрепляет запуск обновления запросом и
ручное обновление, а `hourly-portfolio-sync-history` и фактическая система
используют часовой обход и локальные read-only GET. Пока canonical specs и
AS-IS документация содержат обе трактовки, ни один legacy change нельзя
безопасно синхронизировать или архивировать.
## What Changes
- Зафиксировать единственную observable model: часовой обход обновляет личные
инвестиционные данные, а обычные GET читают сохранённый результат и не
запускают личную синхронизацию.
- Сохранить узкие исключения первой загрузки отсутствующего исторического
интервала операций и впервые открытого диапазона свечей.
- Уточнить видимые состояния свежести, полноты, последней попытки обхода и
доступности личных данных после смены подключения.
- Согласовать canonical behavior specs и durable AS-IS documentation с этой
моделью, не создавая ещё один current OpenAPI source of truth.
## Scope
### In Scope
- Investment accounts, Portfolio, операции изменяемого периода, forecasts/events,
personal connection boundary и связанные frontend recovery states.
- Capabilities `brokerage-accounts`, `portfolio-view`,
`brokerage-account-ledger`, `brokerage-account-upcoming-events`,
`frontend-data-resilience`, `tbank-connection` и `tbank-data-sync`.
- Описание и compatibility guidance для `{ data, meta }` resource responses и
backend-generated OpenAPI как executable HTTP contract.
- Обновление durable documentation и ADR supersession, когда это необходимо.
### Out of Scope / Non-Goals
- Новый способ запуска синхронизации, публичное чтение истории стоимости,
валютная конвертация и многопроцессный scheduler.
- Переписывание historical legacy changes или автоматическое объявление их
approval/Validation успешными.
- Новая product feature, не требуемая для согласования уже поставленного
поведения.
## Capabilities
### New Capabilities
- `hourly-investment-sync`: нормативно зафиксировать текущий наблюдаемый
часовой обход и его статус, если capability ещё отсутствует в canonical specs.
- `portfolio-value-history`: зафиксировать существующую историю оценок и
границу её видимости, если capability ещё отсутствует в canonical specs.
- `online-instrument-information`: зафиксировать online-only справочные
сведения, если capability ещё отсутствует в canonical specs.
### Modified Capabilities
- `brokerage-accounts`: локальное чтение и результат последнего обхода.
- `portfolio-view`: read-only GET, часовая свежесть и отсутствие ручной
личной синхронизации.
- `brokerage-account-ledger`: первая загрузка отсутствующего интервала и
локальное чтение полного прошлого периода.
- `brokerage-account-upcoming-events`: последний complete forecast и
независимое состояние ошибки обновления.
- `frontend-data-resilience`: distinction between retrying an HTTP read and
starting source work.
- `tbank-connection`: границы текущих данных и скрытая история оценок после
смены либо удаления подключения.
## Impact
Пользователь получает предсказуемое обновление инвестиций без скрытого запуска
брокерских запросов при чтении экранов. Backend и frontend уже содержат большую
часть целевого поведения; change формализует его, устраняет расхождения
requirements/docs и создаёт основу для корректного завершения legacy history.
## Success Criteria
- Canonical specs дают один непротиворечивый ответ о владельце обновления,
исключениях первой загрузки и read-only GET.
- Backend-generated OpenAPI/runtime interface семантически соответствует
approved target contract при затронутых HTTP resource responses.
- AS-IS docs и ADR guidance не обещают request-driven/manual sync там, где
фактическая система использует hourly model.
- Legacy `persist-tbank-data` и `hourly-portfolio-sync-history` получают
безопасный дальнейший путь к archival finalization без конкурирующей sync.
## Assumptions / Dependencies
- Часовой scheduler, local resource reads и frontend 30-minute rereads остаются
целевой моделью, подтверждённой Change Owner.
- System Analysis определит точные contracts, semantics forecasts/events и
допустимые first-load exceptions до implementation tasks.
@@ -0,0 +1,92 @@
# QA Analysis
## Verification Scope
### In Scope
- Authorised, black-box verification of the affected personal-resource HTTP reads: Accounts, account and `all` Portfolio, operations, and account and `all` events.
- The distinction between a local ordinary GET/retry and the permitted acquisition paths: the hourly walk, a first unconfirmed historical operations interval, and a first unopened candle range.
- Observable hourly-walk scheduling and outcome semantics, including connection-scoped Accounts `sync` and resource `meta.lastAttempt`.
- Resource-envelope semantics for confirmed data, empty confirmed data, first load, stale fallback, partial/failed attempts, and connection unavailability.
- Current-connection access boundaries after T-Bank connection creation, replacement, and deletion, including retained value-history non-disclosure.
- Events scope, date-window, saved-forecast, and failed-refresh behavior.
- Compatibility of the backend-generated OpenAPI/runtime interface with the approved target envelope and preservation of established route identities and Problem Details behavior.
- User-visible resilience: retry performs an HTTP reread only; cached successful screen data, server fallback, and failed background HTTP reads remain distinguishable.
### Out of Scope
- Scheduler implementation, persistence layout, internal job reservations, upstream-client mechanics, and frontend test structure.
- New public reading of portfolio value-history, manual personal synchronization, currency conversion, multi-process scheduling, and changes to unrelated investment or banking resources.
- Verifying T-Bank or public-reference-provider correctness beyond controlled responses needed to observe WealthLedger's published behavior.
## Coverage Matrix
| Approved behavior | Black-box verification | Expected observable result |
| --- | --- | --- |
| Ordinary personal GETs are read-only | Read Accounts, Portfolio, operations, and events repeatedly, including user retry and screen reopen, while upstream personal calls are observable. | Each read returns only locally held state and causes no personal upstream work or new hourly attempt. |
| First-load exceptions stay narrow | Open an unconfirmed historical operations interval and, separately, an unopened candle range; then reread each after confirmation. | Only the requested missing interval/range may acquire data. It exposes incomplete local state until confirmed; no Accounts, Portfolio, events, other interval, or range refresh occurs. Confirmed reads stay local. |
| Hourly ownership and cadence | Connect successfully; observe initial work, an elapsed eligible term, a restart after downtime, and a term arriving during an active walk. | Connection starts one initial attempt. Subsequent attempts begin no earlier than 60 minutes from the preceding start; restart performs at most one current attempt; overlapping terms neither run nor queue. |
| Hourly result is observational | Observe no attempt, running, succeeded, partial, and failed results through affected resources and Accounts. | `meta.lastAttempt` distinguishes all five states with safe scoped problems; Accounts `sync` is `null` before an attempt and otherwise the connection-wide result. Reading it starts nothing. |
| Independent section persistence | Make one account/section succeed and another fail during a walk. | Successful current data persists; the earlier complete result for the failed section remains visible; the attempt is `partial` with only safe section/account/error-code problem data. |
| Envelope state meanings | Exercise a fresh complete result, stale complete fallback, no complete result while work runs, no complete result after failure, and confirmed empty Accounts/Portfolio/events. | Every success has `{ data, meta }`; `meta` separately reports freshness, completeness, `lastSuccessfulAt`, `lastAttempt`, and availability. `[]` is available and complete, while no complete result is `data: null` and never presented as complete or fresh. |
| Failed refresh preserves known data | Cause a temporary hourly failure after a confirmed Accounts, Portfolio, operations, or events result. | `200` retains the original complete data and success time; freshness reflects that result; the failed attempt is separately visible. A completed background failure is not a synchronous `503`. |
| Invalid connection boundary | Request personal resources with no connection, then after a known-invalid token result. | No connection returns established `409 TBANK_NOT_CONNECTED`; invalid token returns established `400 INVALID_TBANK_TOKEN`, retains the connection record, and discloses no personal fallback data. |
| Connection replacement and deletion | Replace a valid connection while an old walk is pending or running, then delete the replacement twice. | PUT validates before changing the prior connection, establishes a new boundary and initial walk on success; old data/status cannot reappear. DELETE returns `204` idempotently, stops use of its token, and hides current personal data and retained history until the same account is confirmed again. |
| Events query and saved forecast | Read account and `all` events for default, explicit, intersecting, non-intersecting, and empty windows; submit `from > to`; fail a later refresh. | Dates are strict inclusive UTC dates; defaults are current UTC date through +30 calendar days; invalid range returns `400 INVALID_EVENTS_PERIOD` without source work. Results are the intersection with the forecast's original window and retain its `calculatedAt`/window. Empty intersections are successful empty data. `all` is complete only with every included account contribution. Failed refresh retains the previous forecast and reports the attempt failure separately. |
| Frontend recovery and accessibility | Observe a successful resource screen during HTTP reread failure, a server stale/fallback result, a failed hourly attempt, and each no-data first-load state. | Successful cached screen data remains visible during/after background HTTP failure. Retry only rereads HTTP. Client-read failure, server stale data, and failed hourly work are distinct, and the five `meta` meanings are exposed in text or accessible names rather than color alone. |
## Cross-system Verification
1. Use a controlled upstream boundary to prove that normal GETs and retry actions do not cause personal upstream requests, while an eligible hourly walk and each permitted first-load exception have only their approved source effect.
2. Verify the API response and the rendered Accounts, Portfolio, operations, and events states together for the same server conditions. In particular, a saved complete result plus a failed attempt must remain readable and visibly distinguishable from a client HTTP-read failure.
3. Verify that successful PUT replacement and DELETE invalidate visible personal-resource state. Late responses or late old-connection work must not restore accounts, Portfolio, events, `sync`, or retained history across the new boundary.
4. Compare the generated backend OpenAPI published for delivery with runtime responses and the consumer-generated types. Confirm the target `{ data, meta }` resources, Accounts-only `sync`, all required meta meanings, nullability, and enum values are represented without relying on legacy `sync=observe` to start work.
## Risk-based Priorities
| Priority | Risk | Verification focus |
| --- | --- | --- |
| P0 | A screen read initiates brokerage work or leaks data across a connection boundary. | Upstream-call absence on GET/retry; replacement/deletion fence; invalid-token and no-connection responses. |
| P0 | A failed refresh hides or mislabels financial data. | Complete fallback retention; `data: null` when no complete result exists; empty complete results; independent meta and attempt states. |
| P0 | Events return an incorrect period, silently omit an account in `all`, or relabel an old forecast. | Strict UTC window validation/defaults/intersection; `all` completeness; preserved `calculatedAt` and original forecast window after failure. |
| P1 | Scheduler work overlaps, catches up, or reports unsafe/incorrect status. | First walk, 60-minute eligibility from start, downtime, overlap drop, partial result, safe problem scope. |
| P1 | Contract evolution breaks the generated frontend consumer or existing API callers. | Generated OpenAPI/runtime semantic comparison, route and established Problem Details compatibility, legacy observation endpoint non-command behavior. |
| P2 | Presentation obscures recovery and freshness meaning. | Accessible status text, separate client/server/hourly indicators, retained screen snapshot, empty states. |
## Test Data and Environment Needs
- An authenticated user with controlled valid, invalid, and unavailable T-Bank outcomes; at least two supported accounts plus an unsupported account to establish membership and partial-result cases.
- Persisted fixtures for fresh and stale complete Accounts, Portfolio, operations, and forecasts; no-result first-load state; confirmed empty Accounts; empty Portfolio; empty event intersection; and a forecast whose source window differs from the request window.
- Controllable clock and observable upstream boundary to establish hourly eligibility, running overlap, restart behavior, and proof that normal reads have no personal upstream activity.
- Two valid connection identities with disjoint accounts and one shared account, plus retained value-history data, to verify replacement/deletion concealment and re-confirmed access.
- Published backend OpenAPI for the delivered build and the corresponding generated frontend contract, both from the same build candidate.
## Manual / Exploratory Scope
- Navigate Accounts, account Portfolio, operations, and upcoming events through first load, available empty, stale fallback, failed hourly update, invalid token, and no-connection states. For operations, include an incomplete first-loaded interval and its later confirmed result. Confirm status meaning is understandable without color and no manual personal-sync control is exposed.
- Keep an investment screen open across its periodic reread and induce a failed HTTP read. Confirm the prior screen snapshot remains visible and that retry changes only the HTTP-read state.
- Replace or delete a connection while navigating between account and event views. Confirm neither navigation, browser history, nor a late response exposes data from the prior connection.
- Explore boundary dates around UTC midnight, the inclusive forecast window endpoints, leap-day dates, and empty intersections for both account and `all` events scopes.
## Automation Candidates
- Repeatable system checks for the resource-envelope state matrix, hourly cadence/overlap, local-read upstream-call absence, connection revision fence, and event window/scope matrix.
- Consumer-contract compatibility checks that compare the generated backend OpenAPI, runtime resource responses, and regenerated frontend transport types for affected public operations.
- Browser-level checks for retry-only reads, retained successful UI data, invalidation after connection change, and accessible status names.
## Observability Verification
- Confirm resource responses expose no attempt as `none`, running work without `finishedAt`, and terminal succeeded/partial/failed work with only safe section, account, and error-code problem details.
- Confirm `lastSuccessfulAt`, freshness, completeness, and availability continue to describe the returned data rather than the later attempt; `sync` is `null` before any Accounts walk and remains connection-scoped thereafter.
- Confirm no response, OpenAPI example, UI status, or observable problem detail discloses a token, upstream payload, cursor, internal connection revision, or old-connection status.
## Regression Impact
- Existing clients of the affected GET routes must retain route identities, account and `all` scopes, date validation/defaults, response ordering where specified, and established Problem Details codes unless the approved generated contract explicitly changes them.
- Existing `sync=observe` and `/api/v1/tbank-sync/{id}` observation behavior may remain compatible but must not regain request-started personal synchronization semantics.
- Accounts, Portfolio, operations, and events callers must handle the approved envelope evolution, including nullable `data`, independent attempt state, empty complete data, and Accounts-only `sync`; generated frontend types must match the published backend OpenAPI.
- Unaffected public history-reading capability is not introduced. Retained value history remains unavailable outside a confirmed current connection boundary.
## Gaps / Blockers
None identified in the approved System Analysis and deltas. Field names used by the generated OpenAPI remain a delivery compatibility check: they must faithfully represent the approved target semantics before release.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Отсутствующий исторический интервал загружается только впервые
Система MUST позволять авторизованному User при первом открытии ещё не загруженного периода операций доступного Brokerage Account начать получение только недостающего интервала без ожидания T-Bank в локальном ответе. Это является исключением первой загрузки и MUST NOT запускать обновление других личных данных. Полностью загруженный прошлый интервал MUST NOT повторно сверяться из-за чтения страницы или часового обхода. Изменяемый хвост операций MUST обновляться часовым обходом. Незавершённый интервал и ошибка загрузки MUST NOT обозначаться полными, а последний подтверждённый результат MUST оставаться доступным.
#### Scenario: Первый просмотр отсутствующего прошлого периода
- **WHEN** User открывает доступный исторический период без подтверждённой полноты
- **THEN** система возвращает локальный результат с явным состоянием неполноты и начинает загрузку недостающих данных
#### Scenario: Повторное открытие полного периода
- **WHEN** User повторно открывает уже полностью загруженный прошлый период
- **THEN** система читает сохранённые операции без повторной сверки с T-Bank
#### Scenario: Ошибка загрузки
- **WHEN** загрузка части отсутствующего периода завершается ошибкой
- **THEN** ранее подтверждённые операции сохраняются, а неполная часть не помечается полной
@@ -0,0 +1,120 @@
## MODIFIED Requirements
### Requirement: User can retrieve upcoming events for an account
Система MUST предоставлять аутентифицированному User read-only `GET /api/v1/brokerage-account-upcoming-events` для выбранного открытого поддерживаемого Brokerage Account либо существующего режима «Все счета» (`all`) в границах текущего T-Bank connection User. Параметры `from` и `to` MUST сохранять существующий контракт: необязательные строгие календарные даты `YYYY-MM-DD` с включительными границами в UTC. Без `from` начало MUST соответствовать текущей календарной дате UTC; без `to` конец MUST соответствовать дате `from` плюс 30 календарных дней. При `from` позже `to` система MUST возвращать существующую ошибку `INVALID_EVENTS_PERIOD`, не подменять период, не запускать загрузку другого периода и не возвращать его прогноз. Обычный GET MUST NOT запускать личную синхронизацию.
Ответ MUST иметь `{ data, meta }`, где `meta` соответствует наблюдаемому состоянию resource по `tbank-data-sync`. Доступный `data` MUST содержать только события coupon, dividend, maturity и offer, отсортированные по дате по возрастанию, `calculatedAt`, а в режиме `all` -- принадлежность каждого события счёту. Прогноз MUST быть рассчитан по количеству бумаг полного Portfolio Snapshot на `calculatedAt`; сохранённый прогноз MUST сохранять свой исходный `calculatedAt` и рассчитанное от него включительное окно, даже когда запрошенное `from`/`to` отличается. При чтении сохранённого прогноза система MUST возвращать только события, чьи даты попадают во включительное пересечение исходного окна прогноза и запрошенного UTC-окна; пустое пересечение либо отсутствие событий в пересечении MUST возвращать успешный пустой список с теми же `calculatedAt`, `meta` и исходным окном. В режиме `all` доступ и полнота обязательных источников MUST проверяться для каждого входящего счёта; отсутствие вклада счёта MUST NOT подменяться полным результатом. Список будущих событий является прогнозом по наблюдаемому состоянию счёта и количеству бумаг на `calculatedAt` и MUST NOT означать долговременное право владения Brokerage Account независимо от T-Bank connection.
#### Scenario: Получение событий доступного счёта
- **WHEN** аутентифицированный User запрашивает события доступного счёта с последним полным прогнозом
- **THEN** система возвращает из сохранённого прогноза события в запрошенном включительном UTC-окне, его исходные `calculatedAt` и рассчитанное окно без обращения к T-Bank
#### Scenario: Запрошенное окно не пересекается с сохранённым прогнозом
- **WHEN** запрошенный включительный UTC-окно не пересекается с исходным окном последнего полного прогноза
- **THEN** система возвращает успешный пустой `data` с исходными `calculatedAt`, окном и `meta`
#### Scenario: Отсутствие событий в периоде
- **WHEN** пересечение запрошенного и исходного окна прогноза не содержит событий
- **THEN** система возвращает успешный пустой список, момент расчёта, исходное окно и `meta`
#### Scenario: Некорректный период
- **WHEN** User передаёт `from` позже `to`
- **THEN** система возвращает существующую ошибку `INVALID_EVENTS_PERIOD` без прогноза и без запуска личной синхронизации
#### Scenario: Прогноз всех счетов
- **WHEN** User запрашивает режим `all` и для каждого входящего счёта доступен полный прогноз
- **THEN** система возвращает события всех счетов в запрошенном окне с принадлежностью каждого события счёту
#### Scenario: Отклонение недоступного счёта
- **WHEN** User запрашивает события счёта, недоступного через его текущий T-Bank connection
- **THEN** система возвращает существующий ответ о недоступности счёта и не раскрывает события
### Requirement: Forecasts preserve event meaning and uncertainty
Система MUST передавать для каждого события его календарную дату, тип, доступные идентификаторы и название Instrument, а также прогнозную сумму до налогов как Money, если она известна. Прогнозная сумма MUST рассчитываться по количеству бумаг полного Portfolio Snapshot на `calculatedAt`, использованного для сохранённого прогноза, а не по количеству бумаг на момент HTTP-запроса. Сохранённый прогноз MUST сохранять свой `calculatedAt` и эту исходную зависимость, в том числе после неуспешного обновления, и MUST NOT приписываться более новому Portfolio Snapshot. Если денежные условия события неизвестны или неприменимы, прогнозная сумма MUST быть `null`, а не нулём и не парой разрозненных полей. Дивиденд MUST обозначать переданную дату как дату реестра, а offer MUST передаваться без вымышленной суммы.
Instrument в Future Cash Event MUST иметь тот же доменный смысл, что и в Portfolio: это финансовый инструмент, к которому относится событие, даже если событие не является Portfolio Position.
#### Scenario: Прогноз купона по текущей позиции
- **WHEN** поддерживаемая облигация в Portfolio Snapshot на `calculatedAt` имеет известный купон в пределах исходного окна прогноза
- **THEN** система возвращает сохранённое событие coupon с прогнозной суммой до налогов как Money в исходной валюте, рассчитанной по количеству облигаций этого Snapshot
#### Scenario: Прогноз дивиденда
- **WHEN** поддерживаемая акция в Portfolio Snapshot на `calculatedAt` имеет объявленный дивиденд с датой фиксации реестра в пределах исходного окна прогноза
- **THEN** система возвращает сохранённое событие dividend с датой, обозначенной как дата реестра (`record_date`), и прогнозной суммой до налогов как Money, равной `dividend_net` на одну бумагу, умноженной на количество акций этого Snapshot
#### Scenario: Отменённый дивиденд не попадает в прогноз
- **WHEN** запись дивиденда T-Bank имеет тип выплаты `Cancelled`
- **THEN** система не добавляет такое событие в сохранённый прогноз
#### Scenario: Отображение оферты без суммы
- **WHEN** для поддерживаемой облигации известна оферта в пределах исходного окна прогноза, но её денежные условия недоступны
- **THEN** система возвращает событие offer с прогнозной суммой `null`
#### Scenario: Неподдерживаемое частичное погашение
- **WHEN** источник не предоставляет расписание будущих частичных погашений или амортизации облигации
- **THEN** система НЕ создаёт синтетическое событие частичного погашения
### Requirement: Upcoming events use complete source results
Система MUST публиковать прогноз только после полного получения необходимых данных брокера и MOEX для полного Portfolio Snapshot, по количеству бумаг которого он рассчитан. Неуспешное обновление MUST NOT заменять последний полный прогноз, его `calculatedAt` или окно событий частичным списком. При неуспешном обновлении и существующем последнем полном прогнозе система MUST возвращать отфильтрованную для запрошенного окна часть этого прогноза с его исходными `calculatedAt`, окном и `meta`, отдельно обозначающим неуспех последней попытки, даже если результат фильтрации пуст. При отсутствии последнего полного прогноза система MUST возвращать `data: null` и в `meta` обозначать выполнение первой загрузки либо её неуспех без частичного списка. Система MUST пропускать инструменты, для которых источник не предоставляет поддерживаемые события, не создавая строки «нет данных».
#### Scenario: Недоступность источника
- **WHEN** необходимый источник данных брокера или MOEX временно недоступен во время часового обновления
- **THEN** система не публикует частичный список как полный прогноз
#### Scenario: Ошибка обновления при сохранённом прогнозе
- **WHEN** часовой обход не завершает обновление необходимых источников, а последний полный прогноз существует
- **THEN** система возвращает его события только в запрошенном окне, исходные `calculatedAt` и окно, а `meta` отдельно обозначает ошибку обновления
#### Scenario: Ошибка обновления при сохранённом прогнозе вне запрошенного окна
- **WHEN** часовой обход не завершает обновление, последний полный прогноз существует, но его окно не пересекается с запрошенным
- **THEN** система возвращает успешный пустой `data` с исходными `calculatedAt`, окном и `meta`, обозначающим ошибку обновления
#### Scenario: Инструмент без поддерживаемых данных
- **WHEN** Portfolio содержит инструмент, для которого источник не предоставляет поддерживаемые будущие события
- **THEN** система не добавляет для него синтетическую строку в результат
#### Scenario: MOEX отвечает без запрошенного блока
- **WHEN** MOEX отвечает успешно, но не содержит запрошенного блока данных
- **THEN** система трактует ответ как отсутствие данных по инструменту и не завершает обновление ошибкой
### Requirement: Account page presents an upcoming events list
Страница Brokerage Account MUST предоставлять read-only действие перехода на отдельную страницу «Будущие события». Эта страница MUST показывать события сохранённого полного прогноза компактным списком без фильтров, пагинации, суммарного итога или действия ручной синхронизации, с датой, типом, инструментом и прогнозной суммой в исходной валюте, если она известна. Страница MUST показывать `calculatedAt`, исходное окно прогноза и наблюдаемые freshness, полноту, время последнего успешного прогноза, последнюю часовую попытку и доступность из `meta` понятным текстом или иконкой с доступным именем, а не только цветом. Она MUST объяснять, что прогноз рассчитан по количеству бумаг на `calculatedAt`, а показанные события дополнительно ограничены запрошенным включительным UTC-окном; прогноз указан до налогов и может быть неполным из-за доступности данных источника. Открытие страницы и повтор HTTP-чтения MUST NOT запускать личную синхронизацию.
#### Scenario: Просмотр ближайших событий
- **WHEN** User открывает «Будущие события» из обзора Brokerage Account
- **THEN** он видит отсортированный read-only список событий сохранённого 30-календарного окна и `calculatedAt`, где offer обозначен без суммы
#### Scenario: Пустое состояние страницы
- **WHEN** запрос будущих событий успешно возвращает пустой список
- **THEN** страница сообщает, что в сохранённом окне прогноза событий не найдено, показывает `calculatedAt` и объясняет ограничения покрытия источника
#### Scenario: Первая загрузка либо ошибка без сохранённого прогноза
- **WHEN** запрос будущих событий возвращает `data: null`, а `meta` обозначает выполнение первой загрузки либо её неуспех
- **THEN** страница показывает доступное состояние загрузки либо ошибки без частичного списка и предоставляет при ошибке действие повторить только HTTP-чтение
#### Scenario: Ошибка страницы
- **WHEN** запрос будущих событий завершается ошибкой
- **THEN** страница не показывает частичный список и предоставляет действие повторить только HTTP-чтение
#### Scenario: Ошибка обновления при сохранённом прогнозе на странице
- **WHEN** запрос будущих событий возвращает последний полный прогноз вместе с ошибкой его обновления
- **THEN** страница показывает этот прогноз с его исходными `calculatedAt` и окном, отдельно сообщает об ошибке обновления и предоставляет действие повторить только HTTP-чтение
### Requirement: Account overview includes upcoming events
Обзор Brokerage Account MUST показывать не более пяти ближайших Future Cash Event сохранённого полного прогноза в применённом запрошенном окне, его `calculatedAt`, исходное окно, наблюдаемую freshness, полноту, последнюю часовую попытку и доступность из `meta`, а также действие перехода на read-only полную страницу «Будущие события». Статус MUST быть понятен текстом или иконкой с доступным именем, а не только цветом. Неуспешное обновление при сохранённом прогнозе MUST оставаться локальным состоянием блока, сохранять показанные события и их исходное окно и MUST NOT заменять успешно отображённый Portfolio. Если полного прогноза нет, неуспешное обновление MUST оставаться локальным состоянием блока без частичного списка. Обзор MUST NOT предоставлять действие ручной синхронизации; его повтор MUST выполнять только HTTP-чтение.
#### Scenario: Отображение ближайших событий
- **WHEN** обзор счёта успешно загружен и сохранённый прогноз содержит будущие события
- **THEN** обзор показывает первые пять событий в порядке даты, `calculatedAt` и действие перехода к полной странице
#### Scenario: Ближайшие события недоступны без сохранённого прогноза
- **WHEN** обзор счёта успешно загружен, а события возвращают `data: null` и `meta` обозначает выполнение первой загрузки либо её неуспех
- **THEN** Portfolio остаётся доступным, а блок будущих событий отображает собственное доступное состояние загрузки либо ошибки без частичного списка
#### Scenario: Ближайшие события недоступны
- **WHEN** обзор счёта успешно загружен, но запрос будущих событий завершается ошибкой
- **THEN** Portfolio остаётся доступным, а блок будущих событий отображает собственную ошибку
#### Scenario: Неуспешное обновление не скрывает сохранённый прогноз
- **WHEN** обзор счёта получает сохранённый полный прогноз вместе с ошибкой его обновления
- **THEN** Portfolio и события сохранённого окна остаются доступными, а блок отдельно обозначает ошибку и предлагает повторить только HTTP-чтение
@@ -0,0 +1,135 @@
## MODIFIED Requirements
### Requirement: Список активных брокерских счетов
Система SHALL предоставлять авторизованному User `GET /api/v1/brokerage-accounts`, возвращающий `{ data, meta }`, где `data` является сохранённым подтверждённым массивом открытых поддерживаемых инвестиционных счетов T-Bank текущего подключения User в ранее подтверждённом upstream-порядке, а `meta` соответствует наблюдаемому состоянию resource по `tbank-data-sync`. Обычный GET MUST NOT обращаться к T-Bank или запускать личную синхронизацию. Каждый элемент MUST содержать стабильный `id`, непустое `name`, нормализованный `type` (`BROKERAGE`, `INDIVIDUAL_INVESTMENT`, `INVEST_BOX` или `MONEY_MARKET_FUND`) и nullable `openedOn` в формате `YYYY-MM-DD`. Система MUST включать только счета T-Bank типов «брокерский счёт», «ИИС», «Инвесткопилка» и «фонд денежного рынка»; банковские и неизвестные типы MUST NOT возвращаться. Система MUST удалить внешние пробелы из имени и при пустом результате использовать непустое название соответствующего типа.
Каждый элемент MUST также содержать nullable `portfolioSummary`. Доступная сводка MUST содержать `totalsByCurrency`: точные Money-итоги последнего Portfolio по исходным ISO-валютам, включающие стоимость позиций и Cash Balance в той же валюте. Она MUST NOT содержать FX-конвертированный или межвалютный итог и MUST NOT выдавать Cash Balance за самостоятельный второй итог. Если последний Portfolio конкретного доступного счёта отсутствует либо временно недоступен, `portfolioSummary` MUST быть `null`, не исключая этот счёт из списка и не делая список неуспешным. Пустой доступный Portfolio MUST передаваться доступной сводкой с пустым `totalsByCurrency`, а не `null`.
Список Brokerage Account и его сводки являются результатом наблюдения текущего брокерского подключения и MUST NOT создавать ожидание, что WealthLedger хранит долговременное право владения счётом или Portfolio независимо от T-Bank connection.
#### Scenario: Поддерживаемые счета возвращаются в upstream-порядке
- **WHEN** часовой обход подтвердил открытые брокерский счёт, ИИС и неподдержанный дебетовый счёт
- **THEN** обычный GET отвечает `200` этими счетами в `data` в исходном порядке и наблюдаемым состоянием в `meta` без обращения к T-Bank
#### Scenario: Пустой список поддерживаемых счетов
- **WHEN** последний успешный обход подтвердил отсутствие открытых поддерживаемых инвестиционных счетов
- **THEN** система отвечает `200` с `data: []` и `meta`, обозначающим доступный полный результат
#### Scenario: Пустое название получает fallback
- **WHEN** последний подтверждённый счёт имеет пустое либо состоящее из пробелов имя
- **THEN** система отвечает этим счётом с непустым названием его нормализованного типа
#### Scenario: Дата открытия отсутствует
- **WHEN** последний подтверждённый счёт не имеет даты открытия
- **THEN** система отвечает этим счётом с `openedOn: null`
#### Scenario: Мультивалютная сводка счёта
- **WHEN** последний Portfolio счёта содержит итоги в RUB и USD, включая Cash Balance в USD
- **THEN** ответ содержит одну доступную `portfolioSummary` с отдельными Money-итогами RUB и USD без FX-конвертации, общего итога или отдельного итога Cash Balance
#### Scenario: Сводка одного счёта временно недоступна
- **WHEN** подтверждённый список содержит счёт без последнего доступного Portfolio
- **THEN** система отвечает `200` всеми счетами в исходном порядке и передаёт для этого счёта `portfolioSummary: null`
#### Scenario: Доступный Portfolio пуст
- **WHEN** последний доступный Portfolio счёта не имеет ненулевых позиций и доступных денежных остатков
- **THEN** система передаёт доступную `portfolioSummary` с пустым `totalsByCurrency`
### Requirement: Состояния недоступности списка счетов
Система MUST читать подтверждённый список текущего подключения локально. При отсутствии T-Bank connection система MUST отвечать `409` Problem Details с code `TBANK_NOT_CONNECTED`. Если T-Bank отклонил сохранённый token как неаутентифицированный или без прав во время часового обхода, система MUST отвечать `400` Problem Details с code `INVALID_TBANK_TOKEN` и сохранять connection. Временная ошибка обхода MUST сохранять последний подтверждённый список доступным; до первого подтверждённого списка система MUST обозначать продолжающуюся первую загрузку и MUST NOT возвращать частичный список как полный.
#### Scenario: Подключение отсутствует
- **WHEN** User без сохранённого T-Bank connection запрашивает список
- **THEN** система не обращается к T-Bank и отвечает `409` Problem Details с code `TBANK_NOT_CONNECTED`
#### Scenario: Сохранённый token больше не действителен
- **WHEN** T-Bank отклоняет сохранённый token при часовом обходе списка как неаутентифицированный или без прав
- **THEN** последующее чтение отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN` и не раскрывает личные данные
#### Scenario: Ошибка обхода после успешного списка
- **WHEN** часовой обход списка завершается временной ошибкой после ранее успешной проверки
- **THEN** обычный GET возвращает последний подтверждённый список без ожидания T-Bank
#### Scenario: T-Bank временно недоступен
- **WHEN** User запрашивает список до первого подтверждённого списка, а часовой обход завершился временной либо иной upstream-ошибкой
- **THEN** система сообщает о неуспешной первой загрузке без счетов
#### Scenario: Первый обход ещё не завершён
- **WHEN** User открывает список после подключения до получения первого подтверждённого списка
- **THEN** система сообщает о продолжающейся загрузке без частичного либо неподтверждённого списка
### Requirement: Экран списка брокерских счетов
Клиент SHALL загружать список при открытии защищённого маршрута `/accounts` без ручного обновления и без автоматического refetch при возврате фокуса. Он MUST использовать успешный Client Cached Snapshot списка в течение 30 минут. Открытый экран Accounts MUST перечитывать локальный backend-результат каждые 30 минут и MUST NOT запускать импорт T-Bank. Клиент MUST показывать наблюдаемые freshness, полноту, время последнего успешного результата, последнюю часовую попытку и доступность из `meta` понятным текстом или иконкой с доступным именем, а не только цветом. При уже имеющемся успешном списке клиент MUST сохранять последний успешный Client Cached Snapshot видимым во время фонового HTTP-перечитывания и после его ошибки, ясно отделяя это состояние от свежего backend-результата. При успешном непустом ответе клиент MUST отобразить адаптивный одноколоночный список широких интерактивных карточек счетов с локализованными типом и, если доступна, датой открытия без времени; каждая карточка MUST ясно показывать тип, имя, финансовую сводку, вторичные метаданные и заметное действие открытия счёта, вести на `/accounts/:accountId` по всей области и сохранять доступное клавиатурное состояние. После успешной замены или удаления T-Bank connection клиент MUST инвалидировать список счетов.
Для доступной `portfolioSummary` карточка MUST показывать не более двух Money-итогов в исходных валютах и обозначать наличие остальных валют, если они есть; она MUST NOT показывать или подразумевать FX-конвертацию, межвалютный агрегированный итог либо Cash Balance как отдельный второй итог. При `portfolioSummary: null` карточка MUST показывать локальное безопасное состояние временной недоступности сводки, сохраняя метаданные счёта и возможность открыть его. При доступной пустой сводке карточка MUST не выдавать отсутствие итогов за временную ошибку.
#### Scenario: Пользователь открывает счёт из списка
- **WHEN** клиент успешно загрузил непустой список и User выбирает карточку счёта
- **THEN** клиент переходит на `/accounts/:accountId` с `id` выбранного счёта
#### Scenario: Список пуст
- **WHEN** клиент получает успешный пустой массив
- **THEN** клиент показывает штатное сообщение об отсутствии инвестиционных счетов T-Bank
#### Scenario: Последняя попытка неуспешна после полного списка
- **WHEN** клиент получает полный `data` списка и `meta` с неуспехом последней часовой попытки
- **THEN** клиент сохраняет список видимым, показывает его исходное время и текстовое либо доступно именованное предупреждение о неуспешной попытке
#### Scenario: Первая загрузка списка выполняется или неуспешна
- **WHEN** клиент получает `data: null`, а `meta` обозначает выполнение первой загрузки либо её неуспех
- **THEN** клиент показывает соответственно доступное состояние загрузки либо ошибки без карточек счетов и без представления частичного списка как полного
#### Scenario: Список берётся из свежего frontend-кэша
- **WHEN** User повторно открывает список в течение 30 минут после успешной загрузки
- **THEN** клиент показывает сохранённые счета без нового API-запроса
#### Scenario: Устаревший список загружается повторно
- **WHEN** User открывает список после истечения 30 минут frontend-кэша и уже существует последний успешный список
- **THEN** клиент оставляет этот список видимым во время нового HTTP-чтения и ясно обозначает фоновое обновление
#### Scenario: Открытый экран перечитывает список
- **WHEN** экран Accounts остаётся открытым 30 минут после последнего успешного HTTP-чтения
- **THEN** клиент выполняет фоновое HTTP-перечитывание backend-результата, сохраняет видимый snapshot и не запускает импорт T-Bank
#### Scenario: Ошибка фонового обновления не скрывает snapshot
- **WHEN** фоновое HTTP-перечитывание списка завершается ошибкой при наличии последнего успешного Client Cached Snapshot
- **THEN** клиент сохраняет snapshot видимым, безопасно сообщает о неуспешном HTTP-чтении и предоставляет применимое действие повторной загрузки
#### Scenario: Карточка ограничивает валютные итоги
- **WHEN** доступная сводка счёта содержит итоги в трёх или более валютах
- **THEN** карточка показывает не более двух исходных валютных итогов и обозначает наличие остальных без общего сконвертированного значения
#### Scenario: Недоступность сводки локальна карточке
- **WHEN** клиент получает список, в котором `portfolioSummary` одного счёта равна `null`
- **THEN** он показывает временную недоступность только на этой карточке, не скрывает остальные карточки и сохраняет переход на детали недоступной сводки счёта
#### Scenario: Пустая сводка не является ошибкой
- **WHEN** клиент получает доступную `portfolioSummary` с пустым `totalsByCurrency`
- **THEN** он показывает нейтральное состояние отсутствия финансовых итогов, а не состояние временной недоступности
### Requirement: Клиентские состояния ошибки списка счетов
Клиент MUST NOT автоматически повторять неуспешное HTTP-чтение списка. При `TBANK_NOT_CONNECTED` клиент SHALL показать объяснение отсутствия подключения и действие «Открыть настройки T-Bank». При `INVALID_TBANK_TOKEN` клиент SHALL показать объяснение недействительного token и то же действие. При `TBANK_UNAVAILABLE` клиент SHALL показать безопасное объяснение временной недоступности, действие повторного HTTP-чтения и действие открытия настроек T-Bank. При иной ошибке клиент MUST показать общий безопасный error-state только с действием повторного HTTP-чтения. Повтор MUST перечитывать backend-результат и MUST NOT запускать импорт T-Bank. Если последний успешный Client Cached Snapshot отсутствует, эти состояния MUST NOT показывать список; если snapshot существует во время фонового обновления, клиент MUST сохранить его видимым и показать соответствующее безопасное предупреждение и только применимые действия.
#### Scenario: Подключение требуется
- **WHEN** клиент получает `TBANK_NOT_CONNECTED`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Известная ошибка не повторяется автоматически
- **WHEN** HTTP-чтение списка завершается известной ошибкой
- **THEN** клиент не выполняет дополнительные запросы, пока User не выберет действие повторного HTTP-чтения
#### Scenario: Token требует внимания
- **WHEN** клиент получает `INVALID_TBANK_TOKEN`
- **THEN** клиент не показывает список и предлагает открыть настройки T-Bank
#### Scenario: Временная ошибка T-Bank
- **WHEN** клиент получает `TBANK_UNAVAILABLE`
- **THEN** клиент не показывает список и предлагает повторить HTTP-чтение либо открыть настройки T-Bank
#### Scenario: Неизвестная ошибка загрузки
- **WHEN** клиент получает ошибку без известного кода списка счетов
- **THEN** клиент не показывает список и предлагает только повторить HTTP-чтение
#### Scenario: Известная ошибка при сохранённом snapshot
- **WHEN** фоновое HTTP-перечитывание списка с последним успешным Client Cached Snapshot получает `TBANK_UNAVAILABLE`
- **THEN** клиент сохраняет snapshot видимым, сообщает о временной недоступности и предлагает повторить HTTP-чтение либо открыть настройки T-Bank
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Client Cached Snapshot остаётся доступным при обновлении Accounts и Portfolio
Frontend MUST сохранять Client Cached Snapshot списка Brokerage Accounts или Portfolio на экране, если для того же набора данных уже был успешно получен ответ. Начальная загрузка без такого ответа может показывать блокирующее состояние загрузки. Для каждого личного resource response клиент MUST представлять freshness, полноту, время последнего успешного результата, последнюю часовую попытку и доступность из `meta` текстом либо доступно именованным элементом, а не только цветом; он MUST различать отсутствие попытки, выполнение, полный успех, частичный успех, неуспех, доступный пустой полный результат и отсутствие полного результата. Client Cached Snapshot MUST оставаться видимым во время фонового HTTP-перечитывания и после его ошибки; он не является server-side fallback и MUST NOT изменять или интерпретировать поле `Portfolio.isStale`. Повторное действие User MUST повторять только HTTP-чтение и MUST NOT запускать личную синхронизацию. Открытый инвестиционный экран MAY периодически перечитывать backend-результат, но MUST NOT создавать импорт T-Bank.
#### Scenario: Список Brokerage Accounts обновляется в фоне
- **WHEN** экран Accounts уже показывает Client Cached Snapshot и начинается фоновое HTTP-перечитывание того же списка
- **THEN** frontend оставляет список видимым и показывает только неблокирующий индикатор перечитывания
#### Scenario: Обновление Portfolio завершается ошибкой после успешного ответа
- **WHEN** экран Brokerage Account уже показывает Client Cached Snapshot Portfolio, а последующее HTTP-чтение завершается ошибкой
- **THEN** frontend сохраняет Portfolio видимым, показывает локализованное неблокирующее предупреждение и предоставляет повтор только HTTP-чтения
#### Scenario: Server-side fallback и client cached snapshot различаются
- **WHEN** backend возвращает Portfolio с `isStale` либо HTTP-перечитывание Client Cached Snapshot завершается ошибкой
- **THEN** frontend отображает соответствующее server-side либо client-side предупреждение независимо, не представляя одно состояние другим
#### Scenario: Resource сохраняется после неуспешной часовой попытки
- **WHEN** response содержит полный `data` и `meta`, обозначающий неуспех последней часовой попытки
- **THEN** frontend сохраняет `data` и его исходное время видимыми и отдельно обозначает неуспех попытки
@@ -0,0 +1,39 @@
## Purpose
Определяет независимое от открытых экранов часовое обновление личных инвестиционных данных и безопасно наблюдаемый результат последней попытки.
## ADDED Requirements
### Requirement: Часовой обход личных инвестиционных данных
Система SHALL начинать первый часовой обход после успешного подключения T-Bank и последующие обходы не ранее чем через 60 минут от начала предыдущей попытки этого подключения. После простоя система MUST выполнить не более одного актуального обхода и MUST NOT воспроизводить пропущенные часы. Неуспешная попытка MUST NOT повторяться ранее следующего часового срока. Перекрывающиеся обходы MUST NOT выполняться: срок, наступивший во время выполняющегося обхода, MUST быть пропущен и MUST NOT ставиться в очередь; следующий обход становится допустимым только в следующий 60-минутный срок, отсчитанный от начала выполнявшейся попытки. Обход MUST обновлять доступные счета, Portfolio каждого доступного счёта, изменяемый хвост операций и будущие события по бумагам текущего Portfolio. Он MUST NOT повторно загружать полные прошлые интервалы операций, завершённые свечи или полную рыночную историю и MUST NOT зависеть от открытия frontend-экрана.
#### Scenario: Первое подключение
- **WHEN** User успешно подключает T-Bank между часовыми сроками
- **THEN** первый обход начинается без ожидания следующего календарного часа
#### Scenario: Возобновление после простоя
- **WHEN** backend запускается после пропуска нескольких часовых сроков
- **THEN** система выполняет один актуальный обход без синтетических прошлых обновлений
#### Scenario: Перекрытый срок
- **WHEN** предыдущий обход всё ещё выполняется в момент очередного срока
- **THEN** система не начинает второй обход и не ставит пропущенный обход в очередь
#### Scenario: Длительный обход пропускает один срок
- **WHEN** обход начался в 10:00, всё ещё выполняется в 11:00 и завершился до 12:00
- **THEN** система не запускает обход в 11:00 и может начать следующий не ранее 12:00
### Requirement: Последний результат обхода доступен только для наблюдения
Система SHALL предоставлять авторизованному User результат последнего обхода только в границе его текущего подключения: время начала, состояние выполнения, полного успеха, частичного успеха либо неуспеха и безопасный перечень проблемных счетов или разделов. Это состояние MUST быть доступно в `meta` каждого затронутого resource read вместе с freshness, полнотой и доступностью его `data`. Успех одного счёта или раздела MUST сохраняться независимо от ошибки другого; ошибка MUST NOT заменять его последнее успешное состояние или время. Отсутствие состоявшейся попытки MUST отличаться от успешной попытки. Наблюдение результата MUST NOT предоставлять способ начать личную синхронизацию.
#### Scenario: Частичный успех
- **WHEN** Portfolio одного доступного счёта обновился, а другого не обновился
- **THEN** новый Portfolio первого сохраняется, прежний успешный Portfolio второго остаётся доступным, а результат обхода обозначает частичный успех и проблемный счёт
#### Scenario: Наблюдение не запускает обход
- **WHEN** User запрашивает результат последнего обхода
- **THEN** система возвращает доступный сохранённый результат без нового обращения к T-Bank
#### Scenario: Старая граница подключения
- **WHEN** User заменил либо удалил подключение и запрашивает результат прежнего обхода
- **THEN** система не раскрывает результат прежней границы подключения
@@ -0,0 +1,16 @@
## Purpose
Определяет online-only получение справочных сведений и результатов поиска инструментов отдельно от сохранённых личных финансовых данных.
## ADDED Requirements
### Requirement: Справочные сведения и поиск инструментов получают онлайн
Система SHALL получать подробные справочные сведения об Instrument и результаты поиска онлайн при их запросе. Такие сведения MUST NOT становиться самостоятельной долговременной копией справочника. Недоступность либо отсутствие онлайн-сведений MUST NOT задерживать сохранённый финансовый ответ, изменять его свежесть или подтверждать отсутствие Instrument.
#### Scenario: Медленный online lookup
- **WHEN** подробные сведения об Instrument ещё не получены онлайн при чтении сохранённого Portfolio
- **THEN** система возвращает доступный сохранённый Portfolio без ожидания lookup
#### Scenario: Online lookup не найден
- **WHEN** online lookup не возвращает Instrument
- **THEN** система не интерпретирует этот результат как изменение сохранённых личных финансовых данных
@@ -0,0 +1,27 @@
## Purpose
Определяет сохранение точных оценок Portfolio по счёту и валюте для будущей истории без предоставления публичного чтения этих точек.
## ADDED Requirements
### Requirement: Полные оценки Portfolio сохраняются как история
Система SHALL сохранять для каждого доступного Brokerage Account точку оценки по каждой исходной валюте только после успешного получения полного Portfolio с вычислимой стоимостью всех его позиций. Точка MUST содержать точное значение без валютной конвертации и фактическое время успешного получения Portfolio. Система MUST NOT подставлять ноль за отсутствующую стоимость и MUST NOT создавать точку для неполной оценки. Повторная публикация одной попытки MUST NOT создавать дубликат точки.
#### Scenario: Полная мультивалютная оценка
- **WHEN** успешный полный Portfolio содержит вычислимые итоги RUB и USD
- **THEN** система сохраняет две отдельные точки с исходными значениями и валютами
#### Scenario: Неполная оценка
- **WHEN** в новом полном составе Portfolio у позиции отсутствует вычислимая стоимость
- **THEN** система сохраняет новый Portfolio без точки истории оценки
### Requirement: История оценки скрыта вне подтверждённого доступа
Система MUST сохранять историю оценки при замене token или удалении подключения, но MUST NOT раскрывать её без текущего подтверждённого доступа User к тому же Brokerage Account. Успешное новое подключение, подтверждающее доступ к счёту, MUST снова делать его сохранённую историю доступной для будущих поддерживаемых представлений. Система MUST NOT предоставлять отдельное публичное чтение точек истории этим требованием.
#### Scenario: Удалённое подключение
- **WHEN** User удаляет T-Bank connection
- **THEN** сохранённые точки оценки этого User не доступны для чтения
#### Scenario: Повторно подтверждённый счёт
- **WHEN** новое успешное подключение подтверждает доступ User к тому же Brokerage Account
- **THEN** ранее сохранённые точки этого счёта снова доступны для будущих поддерживаемых представлений
@@ -0,0 +1,82 @@
## MODIFIED Requirements
### Requirement: Получение Portfolio выбранного Brokerage Account
Система SHALL предоставлять авторизованному User `GET /api/v1/brokerage-accounts/:accountId/portfolio`. Она MUST возвращать нормализованный полный Portfolio Snapshot только для открытого поддерживаемого Brokerage Account, доступного через текущий T-Bank connection User. Ответ MUST иметь envelope `{ data, meta }`: `data` MUST содержать сохранённый полный Portfolio Snapshot с `lastSuccessfulAt` в RFC 3339 UTC без миллисекунд, boolean `isStale`, `totalsByCurrency`, `positions` и `cashBalances`; `meta` MUST содержать наблюдаемое состояние resource по `tbank-data-sync`, включая freshness, полноту, время последнего успешного Snapshot, последнюю часовую попытку и доступность. Обычный GET MUST читать сохранённый Snapshot и его наблюдаемое состояние без ожидания T-Bank и MUST NOT запускать обновление личного Portfolio.
Portfolio Snapshot является результатом наблюдения Portfolio на конкретный момент. Он MUST NOT означать, что WealthLedger хранит Portfolio как долговременный агрегат или подтверждает доступность Brokerage Account вне текущего T-Bank connection.
#### Scenario: Получение текущего Portfolio
- **WHEN** User запрашивает Portfolio доступного Brokerage Account, а полный Snapshot текущего подключения успешно сохранён
- **THEN** система отвечает `200` с полным нормализованным Portfolio Snapshot в `data`, его `lastSuccessfulAt` и `isStale`, а также наблюдаемым состоянием в `meta`
#### Scenario: Недоступный Brokerage Account
- **WHEN** `accountId` не принадлежит User, закрыт, неподдерживаем или больше не доступен через текущий T-Bank connection
- **THEN** система отвечает `404` без Portfolio Snapshot
### 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. Сохранённая доступная identity позиции MAY оставаться доступной при недоступности online-справочника.
Система MUST делать прежние личные datasets недоступными после успешной замены или удаления подключения. Поздняя работа прежнего подключения MUST NOT публиковаться в новом. Самостоятельные справочные сведения и результаты lookup MUST запрашиваться онлайн, а не сохраняться как долговременный справочник; ошибка lookup не подтверждает отсутствие инструмента.
#### 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 с неизвестной стоимостью позиции без подстановки нуля
### Requirement: Состояния и ошибки Portfolio на клиенте
Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд без automatic retry или refetch при возврате фокуса. Открытый экран MUST перечитывать сохранённый результат backend каждые 30 минут без запуска синхронизации T-Bank. Клиент MUST показывать freshness, полноту, время последнего успешного Snapshot, последнюю часовую попытку и доступность из `meta` текстом или доступно именованным элементом, а не только цветом. Клиент MUST NOT предоставлять действие ручной синхронизации Portfolio. При отсутствии ранее успешного ответа он MUST показывать структурный skeleton с доступным обозначением занятости без вымышленных сумм; skeleton MUST NOT быть интерактивным или получать клавиатурный фокус.
Истечение срока свежести MUST NOT скрывать Client Cached Snapshot. Во время фонового перечитывания и после его ошибки клиент MUST сохранять ранее полученные данные того же Portfolio, исходное `lastSuccessfulAt` и показывать неблокирующую индикацию либо предупреждение с действием повтора только HTTP-чтения. Сохранённые данные MUST NOT считаться новым успешным снимком или подтверждением текущей доступности Brokerage Account. При `isStale: true` клиент MUST показывать Snapshot, исходное время и нейтральное предупреждение без действия ручного обновления. Server-side stale и ошибка Client Cached Snapshot MUST оставаться раздельными состояниями.
При отсутствии Client Cached Snapshot и ответе `404` клиент MUST показать недоступный счёт с возвратом к списку без действия повтора. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` либо иной ошибке клиент MUST показать безопасное error-состояние; повтор HTTP-чтения разрешён только для временной и неизвестной ошибки.
#### Scenario: Показ устаревшего Portfolio
- **WHEN** клиент получает успешный ответ с `isStale: true`
- **THEN** клиент показывает Portfolio, исходное время и нейтральное предупреждение без ручной синхронизации
#### Scenario: Первичная загрузка Portfolio
- **WHEN** клиент ожидает Portfolio без ранее полученного успешного ответа
- **THEN** он показывает структурный skeleton без вымышленных или нулевых сумм и без фокусируемых placeholders
#### Scenario: Неуспешная последняя попытка сохраняет Portfolio
- **WHEN** клиент получает полный Portfolio и `meta` с неуспехом последней часовой попытки
- **THEN** он сохраняет Portfolio и исходное время видимыми и отдельно показывает текстовое либо доступно именованное предупреждение о неуспехе попытки
#### Scenario: Недоступный счёт на клиенте
- **WHEN** для текущего Portfolio отсутствует Client Cached Snapshot и клиент получает `404`
- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора
#### Scenario: Обновление после истечения срока свежести
- **WHEN** срок свежести показанного Portfolio истёк и началось фоновое HTTP-перечитывание
- **THEN** клиент сохраняет прежние валютные итоги и время, показывает неблокирующую индикацию и не заменяет данные skeleton-состоянием
#### Scenario: Ошибка обновления после успешного ответа
- **WHEN** Client Cached Snapshot Portfolio уже видим, а повторное HTTP-чтение завершается ошибкой
- **THEN** клиент сохраняет прежние данные и время, показывает неблокирующее предупреждение и действие повтора чтения
#### Scenario: Ошибка клиентского обновления при server-side stale
- **WHEN** клиент показывает Portfolio с `isStale: true`, а фоновое перечитывание завершается ошибкой
- **THEN** оба предупреждения остаются различимыми, а действие повтора относится только к HTTP-чтению
#### Scenario: Повторное обновление успешно
- **WHEN** после ошибки HTTP-чтения User запускает повторное чтение и получает успешный Portfolio Snapshot
- **THEN** клиент показывает значения и `lastSuccessfulAt` успешного ответа, убирает предупреждение об ошибке чтения и отображает server-side предупреждение только по новому `isStale`
#### Scenario: Штатный пустой Portfolio на клиенте
- **WHEN** успешно полученный Portfolio содержит пустые `positions` и `cashBalances`
- **THEN** клиент показывает empty-состояние без валютных карточек с вымышленными нулевыми итогами
@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Создание и замена подключения T-Bank
Система SHALL позволять авторизованному User создать или заменить единственное подключение через `PUT /api/v1/tbank-connection` с token. Система MUST удалить внешние пробелы token, отклонить пустое значение как ошибку валидации и проверить token запросом T-Bank открытых счетов до сохранения. Пустой список счетов MUST считаться успешной проверкой. Успех MUST вернуть `200` с текущим статусом подключения; успешная замена MUST установить новый `connectedAt`. После успешного подключения MUST начинаться первый часовой обход без команды frontend. История стоимости прежних счетов MUST сохраняться, но MUST быть доступна только для счетов, доступ к которым подтверждён новым подключением.
#### Scenario: Успешное создание подключения без счетов
- **WHEN** User отправляет непустой token и T-Bank успешно возвращает пустой список открытых счетов
- **THEN** система сохраняет подключение, отвечает `200` с `{ connected: true, connectedAt: <RFC 3339 UTC timestamp> }` и не создаёт фиктивные счета
#### Scenario: Успешная замена подключения
- **WHEN** User с подключением отправляет другой token и T-Bank успешно его проверяет
- **THEN** система заменяет token, обновляет `connectedAt`, отвечает текущим статусом без token и начинает первый часовой обход нового подключения
#### Scenario: Невалидный token не заменяет существующее подключение
- **WHEN** T-Bank отклоняет token как неаутентифицированный или не имеющий прав
- **THEN** система отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN` и сохраняет прежнее подключение без изменений
#### Scenario: T-Bank временно недоступен при проверке token
- **WHEN** проверка token не завершается из-за временной или иной upstream-ошибки
- **THEN** система отвечает `503` Problem Details с code `TBANK_UNAVAILABLE` и не создаёт либо не изменяет подключение
### Requirement: Удаление подключения T-Bank
Система SHALL позволять авторизованному User удалить своё подключение через `DELETE /api/v1/tbank-connection`. Удаление MUST быть идемпотентным и отвечать `204`; после успешного удаления backend MUST прекратить использовать удалённый token и сделать относящиеся к User текущие личные данные недоступными. История стоимости счёта MUST сохраняться, но MUST NOT раскрываться до подтверждения нового доступа к тому же счёту.
#### Scenario: Удаление существующего подключения
- **WHEN** User удаляет существующее подключение
- **THEN** система удаляет token, скрывает текущие личные данные и историю стоимости и отвечает `204`
#### Scenario: Повторное удаление подключения
- **WHEN** User удаляет уже отсутствующее подключение
- **THEN** система отвечает `204`, не раскрывая сохранённую историю
@@ -0,0 +1,36 @@
## Purpose
Определяет единственного владельца обновления личных данных T-Bank и узкие
исключения первой загрузки данных, которых ещё нет в локальном результате.
## ADDED Requirements
### Requirement: Часовой обход владеет обновлением личных данных T-Bank
Система MUST обновлять личные инвестиционные данные T-Bank только часовым обходом для текущего подключения User. Обычные resource reads, действия повтора и открытие экранов MUST NOT запускать импорт, ручную личную синхронизацию или обращение к T-Bank. Единственными исключениями являются первая загрузка отсутствующего интервала Account Ledger и впервые открытого диапазона свечей: они MAY начать получение только этого отсутствующего интервала либо диапазона, когда для него нет подтверждённого локального результата. Эти исключения MUST NOT обновлять Accounts, Portfolio, иные интервалы операций, прогноз будущих событий или другой диапазон свечей. После подтверждения полноты запрошенного прошлого интервала или диапазона его повторное чтение MUST оставаться локальным.
#### Scenario: Обычное чтение не запускает импорт
- **WHEN** User открывает Accounts, Portfolio, Account Ledger, будущие события либо повторяет неуспешное HTTP-чтение
- **THEN** система читает локальный результат и не начинает личную синхронизацию T-Bank
#### Scenario: Первый отсутствующий интервал операций
- **WHEN** User впервые открывает интервал Account Ledger без подтверждённого локального результата
- **THEN** система MAY начинает получение только этого интервала и возвращает его локальное состояние неполноты до подтверждения результата
#### Scenario: Впервые открытый диапазон свечей
- **WHEN** User впервые открывает диапазон свечей, для которого нет подтверждённого локального результата
- **THEN** система MAY начинает получение только этого диапазона и не загружает другие диапазоны или полную историю свечей
### Requirement: Результат личного обновления остаётся наблюдаемым
Каждый response resource read личных данных текущего подключения MUST возвращать `{ data, meta }`. `meta` MUST отдельно делать наблюдаемыми freshness и полноту именно `data`, момент последнего успешного полного результата resource, момент и состояние последней часовой попытки, затронувшей resource, и доступность resource. Состояние последней попытки MUST различать отсутствие попытки, выполнение, полный успех, частичный успех и неуспех. Доступность MUST различать доступный полный результат, первую загрузку без полного результата, временную недоступность без полного результата и недоступность из-за отсутствующего либо недействительного подключения. Неполный, продолжающийся или неуспешный результат MUST NOT обозначаться полным или свежим. Успешный пустой Accounts либо иной пустой полный результат MUST обозначаться доступным и полным, а не отсутствующим. При неуспешном обновлении последний подтверждённый полный результат и его время MUST оставаться доступными, когда отдельное требование capability не предписывает недоступность данных; его freshness MUST определяться исходным временем, а `meta` MUST отдельно обозначать неуспех последней попытки. Наблюдение этих состояний MUST NOT предоставлять способ начать синхронизацию.
#### Scenario: Ошибка после полного результата
- **WHEN** очередное обновление набора личных данных завершается ошибкой после ранее подтверждённого полного результата
- **THEN** система возвращает предыдущий полный `data`, его исходное время и freshness, а `meta` отдельно обозначает неуспех последней попытки
#### Scenario: Первая загрузка ещё не завершена
- **WHEN** для набора личных данных ещё нет подтверждённого полного результата
- **THEN** система возвращает `data: null` и в `meta` обозначает выполнение первой загрузки либо её неуспех без частичного результата, выданного за полный
#### Scenario: Успешный пустой список счетов
- **WHEN** последний полный результат Accounts подтверждает отсутствие открытых поддерживаемых счетов
- **THEN** система возвращает `data: []`, а `meta` обозначает доступный полный результат, его время и состояние последней часовой попытки
@@ -0,0 +1,108 @@
# System Analysis
## Requirement Coverage
| Approved delta | System decision covered here |
| --- | --- |
| `hourly-investment-sync`: hourly walk and observable result | Per-connection durable hourly attempt, no catch-up or overlap, and `lastAttempt`/Accounts `sync` semantics. |
| `tbank-data-sync`: one update owner and observable resource state | Local resource reads, two narrow first-load exceptions, and the target `{ data, meta }` contract. |
| `brokerage-accounts` | Saved Accounts and per-account summaries, plus the connection-wide hourly result exposed as `sync`. |
| `portfolio-view` and `portfolio-value-history` | Saved complete snapshots, stale fallback, connection-revision publication fence, and private retained value history. |
| `brokerage-account-ledger` | First opening of an unconfirmed historical interval may load only that interval; complete history remains local. |
| `brokerage-account-upcoming-events` | Saved forecast query/window semantics, complete-source publication, and independent failed-update status. |
| `online-instrument-information` | Online reference lookup is separate from personal-resource reads and cannot delay or alter their status. |
| `frontend-data-resilience` | A retry reissues only HTTP GET; frontend cache, server fallback, and hourly-attempt failure remain distinct. |
| `tbank-connection` | PUT starts the new connection's first walk; replacement/deletion hides old personal data and old walk status. |
## Systems and Responsibilities
| System | Responsibility | Must not do |
| --- | --- | --- |
| Frontend | Read resources, render `data` and all observable `meta` fields, retain its own successful screen cache, and retry only the GET. | Start personal sync, infer freshness from a client error, or expose a manual personal-sync action. |
| Backend resource API | Authenticate, enforce the current connection boundary, read local persisted resources, and return the target envelope. | Wait for or call T-Bank while serving ordinary resource GETs. |
| Durable hourly scheduler | Reserve at most one current walk per connection; refresh membership, each available Portfolio, mutable operations tail, then events. | Replay missed terms, queue an overlapped term, or refresh complete historical ledger intervals/candle ranges. |
| Local store | Keep last confirmed complete data, coverage, resource timestamps, hourly attempt/result, and connection revision. | Publish data or status from a superseded connection revision. |
| T-Bank and public reference sources | Supply upstream data to connection PUT validation, the hourly walk, and explicitly online reference lookups. | Become a synchronous dependency of a normal personal resource GET. |
## Interaction Flows
`sequences/read-only-resource-get.mmd` defines the normal read and retry boundary. A `200` read uses only local state; a boundary error may occur before any data lookup.
`sequences/hourly-investment-walk.mmd` defines the sole normal personal-data updater. A successful connection starts its first attempt immediately. Thereafter a next attempt is eligible no earlier than 60 minutes after the preceding attempt started. Startup performs at most one current attempt, not a replay. If a term arrives while an attempt is running, it is dropped rather than queued; the next eligible term is measured from the running attempt's start.
The walk updates current membership, every available account's Portfolio, each account's mutable operations tail, and forecasts/events for instruments in the current Portfolio. Each successful section persists independently. A failed section preserves its earlier complete result and its original success time. Complete past operation intervals, completed candles, and full market history are not refreshed by the walk.
### First-load exceptions
1. Opening an unconfirmed historical Account Ledger interval may start retrieval of only that missing interval and immediately returns local rows plus incomplete coverage/status. It does not refresh membership, Accounts, Portfolio, events, another operations interval, or candles. A confirmed complete interval is never rechecked due to a GET or the hourly walk.
2. Opening a candle range with no confirmed local result may start retrieval of only that range. It does not retrieve other ranges or all history; a confirmed range is subsequently local.
These are acquisition exceptions, not variants of manual sync. HTTP retry of either resource rereads its local state; it does not broaden or restart work.
## Contracts
`contracts/investment-resource-envelope.schema.json` is the minimal target cross-system artifact. It describes the changed resource envelope rather than duplicating backend `openapi.json`.
All affected personal resource successes use `{ data, meta }`: Accounts; account and `all` Portfolio; account and `all` operations; account and `all` events. `data` is the last confirmed complete result, except that ledger first-load may return locally held rows with `meta.completeness: "incomplete"`. Where no complete result exists, `data` is `null`; a partial result must never be represented as complete.
`meta` has five independent meanings:
| Field | Target meaning |
| --- | --- |
| `freshness` | `fresh`/`stale` applies to the timestamp of the returned confirmed result; `unknown` applies when none exists. A failed or running attempt never makes data fresh. |
| `completeness` | `complete` only after the resource's required source result is confirmed. `incomplete` describes an in-progress/failed first-load or ledger coverage gap. |
| `lastSuccessfulAt` | Timestamp of the returned complete result, or `null` when none exists. It is not replaced by a later failed attempt. |
| `lastAttempt` | The resource-relevant hourly result: `none`, `running`, `succeeded`, `partial`, or `failed`, with nullable start/end timestamps and only safe failed section/account/error-code entries. `partial` means at least one section succeeded and at least one failed. |
| `availability` | `available` has a complete result, including `[]`; `initial-loading` and `temporarily-unavailable` have no complete result; `connection-unavailable` means the current connection cannot expose the resource. |
Accounts also returns top-level `sync`: `null` if the current connection has no hourly attempt, otherwise the complete connection-wide last-walk status. Its `problems` list is the safe, scoped failure list for `membership`, `portfolio`, `operations`, and `events`; an account ID is present only for account-scoped sections. `sync` is observation only and is not a command or progress API.
Events preserves the existing account and `all` scope. `from` and `to` are optional strict `YYYY-MM-DD` UTC dates with inclusive bounds; omitted `from` is the current UTC date and omitted `to` is `from + 30` calendar days. `from > to` returns `400 INVALID_EVENTS_PERIOD`, does not read a substitute range, and starts no source work. A saved forecast retains `calculatedAt` and its original inclusive calculation window. The response filters events to the intersection of that original window and the requested window. Empty intersection and no matching events are successful empty `data` with the same forecast metadata. `all` is complete only if every included account has its required complete forecast sources; it cannot hide a missing account contribution. Events' `meta.lastAttempt` reports the resource-relevant hourly events result independently of the saved forecast, so a failed update can coexist with an available forecast.
## Data and State Semantics
An empty confirmed Accounts list, empty Portfolio, empty event intersection, and empty event list are all available complete results, never missing data. A stale complete result remains available indefinitely until a connection boundary makes it unavailable. `Portfolio.isStale` retains its snapshot-specific meaning and is not a substitute for `meta.lastAttempt` or a frontend request error.
Forecast publication requires complete broker and public-source results for the Portfolio Snapshot used to calculate it. It stores the Portfolio quantity basis, `calculatedAt`, and source window together. A failed refresh cannot publish a partial forecast or relabel an older forecast as calculated from a newer Portfolio.
Successful complete Portfolio evaluations may append one exact, per-currency value-history point at the actual successful Portfolio time. Missing valuation must not be replaced with zero and cannot create a point. Retry/publication of one attempt is idempotent. History survives connection replacement/deletion but is readable only after the current connection confirms access to the same account; no public history-read endpoint is introduced.
The connection revision is the publication fence. PUT validates the candidate token, atomically establishes a new revision and membership boundary, then triggers its first walk. DELETE is idempotent, prevents further token use, and hides current personal data. Old attempts cannot publish into a replacement connection, and neither old `sync` nor retained value history is disclosed across an unconfirmed boundary.
## Error and Recovery Semantics
Authentication errors remain HTTP boundary errors. No connection is `409 TBANK_NOT_CONNECTED`. A token known invalid/unauthorized from an hourly attempt is `400 INVALID_TBANK_TOKEN`; it retains the connection record but blocks personal-data disclosure and must not be masked by fallback data. A requested account outside current confirmed membership is `404`; unknown membership does not authorize an arbitrary account ID. Connection PUT validation failures remain `400 INVALID_TBANK_TOKEN` or `503 TBANK_UNAVAILABLE` and leave the prior connection unchanged.
Temporary upstream failure during an hourly attempt is represented in `meta.lastAttempt` and, on Accounts, `sync`; it is not a synchronous GET failure. With a previous complete result, GET returns `200` and the preserved data. Without one, GET returns `{ data: null, meta }` with `initial-loading` or `temporarily-unavailable`. `503 TBANK_UNAVAILABLE` remains reserved for an HTTP request that itself cannot be served, not a completed background failure.
Frontend recovery is limited to rereading the resource. It retains its Client Cached Snapshot during a background HTTP failure, while separately showing server stale/fallback and the failed hourly attempt where supplied. Replacing/deleting a connection invalidates personal client queries so a late response cannot revive hidden data.
## Observability Semantics
The durable hourly attempt is the observable unit: start time, terminal state, finish time, and safe failed section/account/error-code list. Absence of an attempt is `none` in resource `lastAttempt` and `null` in Accounts `sync`; it is not success. A running attempt has `finishedAt: null`. The status is scoped to the current connection revision and contains no token, upstream payload, cursor, or internal revision.
Resource `lastSuccessfulAt`, freshness, completeness, and availability describe returned `data`; the hourly result describes the attempt. This separation permits a truthful response after partial/failed refresh. Frontend status text or accessible names must expose all five resource meanings and must not rely on color alone.
## Security and NFR at System Boundaries
All personal reads require the authenticated User and current-connection access check. T-Bank tokens remain server-only, encrypted, and absent from response metadata, OpenAPI examples, logs, and status failures. Online instrument reference failure cannot prove an instrument absent or alter personal snapshot freshness.
The scheduler is a single durable reservation owner for the deployed single backend process. The target explicitly does not add multi-process scheduling. Local read latency is isolated from upstream latency because ordinary GETs do not invoke upstream personal data calls. The existing upstream limiter remains the boundary for PUT validation and hourly work.
## Compatibility / Migration
The current backend-generated `openapi.json` and frontend snapshot are identical and already expose resource envelopes, Accounts top-level `sync`, and events `sync=observe`; they are evidence of AS-IS, not the target authority. Current generated `InvestmentResourceMetaDto` uses `availability: missing|partial|complete` plus per-work `sync: idle|running|failed`, while `HourlySyncStatusDto` adds `cancelled`; these do not fully express the approved target's first-load availability, `none|succeeded|partial` attempt distinction, and resource-scoped last attempt. Current `sync=observe` and `/api/v1/tbank-sync/{id}` preserve legacy screen-work observation and must not be used to reintroduce request-started personal synchronization.
Implementation must evolve backend Swagger DTOs/controllers and regenerate backend OpenAPI. The frontend must receive that published snapshot and regenerate its local transport types. The HTTP route identities and established Problem Details codes remain compatible unless the generated target contract explicitly changes them; no copy of the full OpenAPI belongs in this change. The target schema is intentionally additive/semantic and must be reconciled with generated field names before release.
## Durable Documentation Candidates
- `docs/adr/019-hourly-reads-and-online-instrument-reference.md`: clarify that hourly status is connection-scoped and that only the two first-load exceptions may acquire personal/market data.
- `docs/architecture/cross-cutting/data-freshness.md`: replace legacy request-driven freshness table and `sync=observe` language with target resource/attempt separation and `data: null` first-load states.
- `docs/domains/portfolio/flows/portfolio-loading.md`: replace screen-triggered refresh/status flow with local GET and hourly-walk flow.
- `docs/domains/income/upcoming-events.md`: describe saved forecast source-window intersection, `calculatedAt`, and independent failed hourly update state.
- `docs/adr/017-local-tbank-reads-and-background-sync.md`: mark its request-driven/manual-refresh decisions superseded by ADR 019 rather than leaving competing guidance.
## Open Questions
None. The approved Proposal and Delta Specs determine the target semantics; implementation will reconcile field naming and route annotations through backend-generated OpenAPI without treating current generated artifacts as a second authority.
@@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "reconcile-hourly-investment-sync/investment-resource-envelope.schema.json",
"title": "Target investment resource envelope",
"description": "Target cross-system semantics only; the backend-generated OpenAPI remains the executable HTTP contract.",
"type": "object",
"required": ["data", "meta"],
"properties": {
"data": {
"description": "The last confirmed complete result, an allowed incomplete ledger first-load result, or null when no complete result is available."
},
"meta": { "$ref": "#/$defs/resourceMeta" },
"sync": { "$ref": "#/$defs/hourlyWalk" }
},
"$defs": {
"resourceMeta": {
"type": "object",
"required": ["freshness", "completeness", "lastSuccessfulAt", "lastAttempt", "availability"],
"properties": {
"freshness": { "enum": ["fresh", "stale", "unknown"] },
"completeness": { "enum": ["complete", "incomplete"] },
"lastSuccessfulAt": { "type": ["string", "null"], "format": "date-time" },
"lastAttempt": { "$ref": "#/$defs/attempt" },
"availability": { "enum": ["available", "initial-loading", "temporarily-unavailable", "connection-unavailable"] }
}
},
"attempt": {
"type": "object",
"required": ["state", "startedAt", "finishedAt", "problems"],
"properties": {
"state": { "enum": ["none", "running", "succeeded", "partial", "failed"] },
"startedAt": { "type": ["string", "null"], "format": "date-time" },
"finishedAt": { "type": ["string", "null"], "format": "date-time" },
"problems": { "type": "array", "items": { "$ref": "#/$defs/problemSection" } }
}
},
"hourlyWalk": {
"description": "Present only on Accounts; absent from other resource envelopes.",
"oneOf": [
{ "type": "null" },
{ "$ref": "#/$defs/attempt" }
]
},
"problemSection": {
"type": "object",
"required": ["section", "accountId", "errorCode"],
"properties": {
"section": { "enum": ["membership", "portfolio", "operations", "events"] },
"accountId": { "type": ["string", "null"] },
"errorCode": { "type": ["string", "null"] }
}
}
}
}
@@ -0,0 +1,18 @@
sequenceDiagram
participant Scheduler
participant DB as Durable reservation/status
participant TB as T-Bank and public sources
participant Store as Local resource store
participant API as Resource GET
Scheduler->>DB: Reserve current connection walk
alt Due and no walk running
DB-->>Scheduler: Attempt with connection revision
Scheduler->>TB: Refresh membership, portfolios, mutable operations, events
Scheduler->>Store: Publish each complete successful result for the same revision
Scheduler->>DB: Settle succeeded, partial, or failed with safe failed sections
else Not due or an attempt is running
DB-->>Scheduler: No work
end
API->>Store: Read persisted result and attempt status
Store-->>API: Last confirmed data and metadata
@@ -0,0 +1,13 @@
sequenceDiagram
actor User
participant UI as Frontend
participant API as Backend resource GET
participant DB as Local store
participant TB as T-Bank
User->>UI: Open screen or retry HTTP read
UI->>API: GET resource
API->>DB: Authorize current connection and read saved result/status
DB-->>API: data, resource state, last hourly attempt
API-->>UI: 200 { data, meta } or boundary Problem Details
Note over API,TB: No T-Bank call and no personal sync work from GET or retry

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