diff --git a/openspec/changes/add-portfolio-view/.openspec.yaml b/openspec/changes/archive/2026-08-13-add-portfolio-view/.openspec.yaml similarity index 100% rename from openspec/changes/add-portfolio-view/.openspec.yaml rename to openspec/changes/archive/2026-08-13-add-portfolio-view/.openspec.yaml diff --git a/openspec/changes/archive/2026-08-13-add-portfolio-view/design.md b/openspec/changes/archive/2026-08-13-add-portfolio-view/design.md new file mode 100644 index 0000000..1fc4e3f --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-portfolio-view/design.md @@ -0,0 +1,75 @@ +## Context + +См. мотивацию в `proposal.md` и поведение в `specs/portfolio-view/spec.md`. Backend уже хранит один зашифрованный T-Bank token на User, централизует TLS gRPC, limiter, deadline и retry, а также публикует authenticated OpenAPI. `BrokerageAccount` уже является нормализованной границей доступного открытого инвестиционного счёта. Маршрут `/accounts/:accountId` существует как placeholder; frontend использует FSD, Mantine, React Query и generated OpenAPI types. + +T-Bank `GetPortfolio` предоставляет обычные позиции с количеством и ценами, а `GetPositions` -- доступные и заблокированные денежные остатки. Обе protobuf-модели используют `int64`/`nano`; JavaScript `number` не обеспечивает безопасной финансовой арифметики. + +## Goals / Non-Goals + +**Goals:** + +- Расширить существующую server-side T-Bank boundary полным read-path одного Portfolio. +- Нормализовать upstream-данные в стабильный публичный HTTP contract с точными decimal strings. +- Кэшировать полный user-scoped Portfolio Snapshot и глобальные metadata инструментов без новой инфраструктуры. +- Реализовать адаптивный detail UI в визуальном направлении `references/fin-tracker`, следуя FSD и code conventions `references/frontend-ai-template`, без переноса кода из референсов. +- Документировать фактическое поведение в `docs/features/portfolio-view.md` после реализации. + +**Non-Goals:** + +- Новые зависимости, persistent/distributed cache, ограничение размера in-memory cache или поддержка нескольких backend instances. +- История Portfolio, операции, виртуальные позиции, заблокированные средства, вывод средств, графики, polling, ручное обновление и валютная конвертация. +- Изменение Prisma schema, T-Bank connection lifecycle или существующей общей resilience policy. + +## Decisions + +### Read-path, авторизация счёта и error mapping + +`GET /api/v1/brokerage-accounts/:accountId/portfolio` читает connection User и использует `GetAccounts` для проверки, что requested id остаётся открытым поддерживаемым Brokerage Account. Отсутствующий, закрытый, неподдерживаемый или недоступный id получает `404` до финансовых RPC. При валидном счёте `GetPortfolio` и `GetPositions` выполняются параллельно; новый Snapshot создаётся только при успехе обоих вызовов. `UNAUTHENTICATED` и `PERMISSION_DENIED` остаются `400 INVALID_TBANK_TOKEN`, отсутствие connection -- `409 TBANK_NOT_CONNECTED`, остальные upstream-сбои -- `503 TBANK_UNAVAILABLE`. + +Это переиспользует единственную T-Bank policy и не допускает частичного финансового состояния. Прямая доверенность `accountId` позволила бы запрашивать устаревшие или чужие ids; частичный ответ смешивал бы разные моменты upstream-состояния. + +### Нормализованная модель и точная арифметика + +Backend добавляет публичные Portfolio DTO и один сервис нормализации. Он берёт обычные позиции только из `GetPortfolio.positions`, доступные деньги только из `GetPositions.money`, исключает virtual и blocked данные, удаляет нулевые элементы и сохраняет upstream-порядок. Инструментные типы переводятся в фиксированный union с `OTHER` fallback. Metadata по FIGI обогащает отображаемый идентификатор, но не входит в критерий успешности Snapshot. + +Десятичные protobuf-пары преобразуются в signed integer nanos и обратно в canonical decimal strings через Node `BigInt`. Этим же представлением вычисляются `currentValue`, `absoluteReturn`, Cash Balance и агрегаты валют; API никогда не передаёт финансовые `number`. `absoluteReturn` публикуется только при одинаковой известной валюте текущей и средней цены. + +Альтернативы: `number` теряет точность `int64` и дробных значений; decimal dependency увеличивает surface там, где достаточно `BigInt`; использование `GetPositions` для ценных бумаг создало бы дубли и не содержит требуемых оценок. + +### Snapshot и metadata caches + +Один in-memory Portfolio cache ключуется парой User и Brokerage Account. Успешный полный Snapshot считается свежим 30 секунд и пригоден как fallback до пяти минут. Если обновление временно неуспешно, сервис возвращает сохранённый Snapshot с `isStale: true`; invalid token никогда не маскируется fallback. Successful replace/delete connection очищает snapshots этого User. + +Отдельный общий in-memory cache по FIGI хранит successful metadata и подтверждённое отсутствие 24 часа. Для нового Snapshot отсутствующие metadata запрашиваются параллельно и ожидаются в пределах gRPC deadline: при успешном ответе используется name. Ошибки metadata lookup не кэшируются. Cache metadata не содержит user data и не очищается при изменении connection. + +Ограничение количества записей не добавляется: MVP рассчитан на единственного User, Snapshot живут не более пяти минут. При multi-user deployment заменить эти caches на shared bounded cache вместе с distributed rate-limit, а не добавлять частичный LRU сейчас. + +### OpenAPI и frontend detail page + +Controller документирует DTO через Nest Swagger; frontend генерирует schema из работающего backend и выводит transport aliases из `paths[...]`. Новый `portfolio` entity инкапсулирует query с `staleTime: 30_000` и отключённым retry; page импортирует entity только через public barrel. После stale time previous data не показывается до нового результата. + +`AccountPage` выбирает loading, empty, unavailable-account, known-error, generic-error или loaded view. Loaded view использует локальные Mantine/CSS-module компоненты: агрегаты по валютам, отдельные секции акций и облигаций с upstream-порядком внутри каждой, общую секцию остальных позиций, Cash Balance и freshness warning. Название и тип счёта берутся из уже имеющегося account-list cache; при прямом переходе остаётся заголовок «Портфель». Денежный formatter выводит RUB как `₽`. Дизайн сохраняет светлые поверхности, тонкие slate-границы, синий accent и читаемый single-column mobile layout референса, без его кода и placeholder-метрик. + +Альтернативы: ручные frontend DTO расходятся с OpenAPI; дополнительный account endpoint ради заголовка добавляет вызов без доменной пользы; перестановка строк по сумме сравнивала бы разные валюты. + +### Documentation and validation + +`docs/features/portfolio-view.md` фиксирует пользовательский поток, endpoint, публичную модель, точность значений, cache/fallback, ошибки и Mermaid sequence diagram. ADR не создаётся: server-side boundary, in-memory resilience и frontend query policy уже определены существующими ADR; конкретные TTL и normalisation относятся к feature. ADR 005 уточняется только если его общая формулировка противоречит фактической cache policy после реализации. + +## Risks / Trade-offs + +- [Snapshot может быть до 30 секунд старым] → это согласованный TTL; после него счёт заново проверяется через T-Bank. +- [Fallback может показать данные до пяти минут] → клиент явно показывает `isStale` и исходный `lastSuccessfulAt`. +- [Metadata lookup расходует upstream quota] → результат и отсутствие кэшируются 24 часа; финансовый ответ не блокируется его ошибкой. +- [In-memory cache теряется при restart и не масштабируется] → MVP использует один instance; перед горизонтальным масштабированием нужны shared cache и limiter. +- [T-Bank вводит новый тип инструмента] → он отображается как `OTHER`, не скрывается. + +## Migration Plan + +1. Расширить T-Bank client RPC для accounts, portfolio, positions и instrument metadata; добавить exact decimal normalizer, cache и user-scoped Portfolio service. +2. Добавить controller, Swagger DTO и backend unit/e2e проверки нормализации, агрегации, fallback, invalid token, `404` и auth. +3. Запустить backend, перегенерировать frontend OpenAPI client и добавить portfolio entity/query. +4. Заменить account placeholder state-вьюхами и loaded UI; проверить desktop/mobile. +5. Создать feature documentation, прогнать backend/frontend quality gates и OpenSpec validation. + +Rollback до release удаляет endpoint и frontend entity/page view. Prisma migration отсутствует; connection и его encrypted token не затрагиваются. diff --git a/openspec/changes/add-portfolio-view/proposal.md b/openspec/changes/archive/2026-08-13-add-portfolio-view/proposal.md similarity index 90% rename from openspec/changes/add-portfolio-view/proposal.md rename to openspec/changes/archive/2026-08-13-add-portfolio-view/proposal.md index 2254099..01e4945 100644 --- a/openspec/changes/add-portfolio-view/proposal.md +++ b/openspec/changes/archive/2026-08-13-add-portfolio-view/proposal.md @@ -6,7 +6,7 @@ - Добавить защищенный API портфеля выбранного счета с агрегатами, всеми типами позиций и денежными остатками. - Передавать денежные значения как десятичные строки с кодом валюты без конвертации и без арифметики на JavaScript float. -- Добавить страницу `/accounts/:accountId` с позициями, сортировкой по текущей стоимости по убыванию, пустым портфелем и временем последнего успешного upstream-ответа. +- Добавить страницу `/accounts/:accountId` с позициями в upstream-порядке, пустым портфелем и временем последнего успешного upstream-ответа. - Получать и кэшировать метаданные инструментов; при недоступности названия или тикера показывать `figi`, не скрывая финансовые значения. - Документировать портфель, fallback-правила и различие штатного пустого состояния от upstream-ошибки. diff --git a/openspec/changes/archive/2026-08-13-add-portfolio-view/specs/portfolio-view/spec.md b/openspec/changes/archive/2026-08-13-add-portfolio-view/specs/portfolio-view/spec.md new file mode 100644 index 0000000..e21327c --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-portfolio-view/specs/portfolio-view/spec.md @@ -0,0 +1,106 @@ +## Purpose + +Позволяет авторизованному User просматривать нормализованный текущий состав и агрегаты Portfolio одного доступного Brokerage Account без валютной конвертации. + +## ADDED Requirements + +### Requirement: Получение Portfolio выбранного Brokerage Account +Система SHALL предоставлять авторизованному User `GET /api/v1/brokerage-accounts/:accountId/portfolio`. Она MUST возвращать нормализованный полный Portfolio только для открытого поддерживаемого Brokerage Account, доступного через текущий T-Bank connection User. Ответ MUST содержать `lastSuccessfulAt` в RFC 3339 UTC без миллисекунд, boolean `isStale`, `totalsByCurrency`, `positions` и `cashBalances`. + +#### Scenario: Получение текущего Portfolio +- **WHEN** User запрашивает Portfolio доступного Brokerage Account, а обязательные upstream-данные успешно получены +- **THEN** система отвечает `200` полным нормализованным Portfolio с `isStale: false` и временем успешного получения финансового снимка + +#### Scenario: Недоступный Brokerage Account +- **WHEN** `accountId` не принадлежит User, закрыт, неподдерживаем или больше не доступен через текущий T-Bank connection +- **THEN** система отвечает `404` без Portfolio + +### Requirement: Нормализованный состав Portfolio +Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок 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`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения. + +#### Scenario: Позиция с неизвестным upstream-типом +- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента +- **THEN** система возвращает её в исходном порядке с типом `OTHER` + +#### 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: Денежные остатки в одной валюте +- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты +- **THEN** система возвращает один Cash Balance с их точной суммой + +#### Scenario: Штатный пустой Portfolio +- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков +- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency` + +### Requirement: Точные значения и валютные агрегаты +Все количества и финансовые значения в Portfolio MUST передаваться десятичными строками без экспоненциальной записи и без незначащих завершающих нулей. Денежное значение MUST содержать ISO-код валюты. Система MUST корректно нормализовать отрицательные целую и дробную части upstream-значений. Отсутствующее значение MUST передаваться как `null`, а не как ноль. + +`currentValue` Portfolio Position MUST быть точным произведением количества на текущую цену при наличии валидных множителей и валюты цены. `absoluteReturn` MUST быть точной величиной `(currentPrice - averagePurchasePrice) * quantity` только когда обе цены имеют одинаковую известную валюту. Позиция без вычислимой текущей стоимости MUST оставаться в ответе, но MUST NOT входить в агрегаты. `totalsByCurrency` MUST содержать точную сумму currentValue позиций и Cash Balance в каждой валюте без валютной конвертации. + +#### Scenario: Мультивалютный Portfolio +- **WHEN** Portfolio содержит финансовые значения в RUB и USD +- **THEN** система возвращает отдельные агрегаты RUB и USD без общего сконвертированного итога + +#### Scenario: Позиция без вычислимой стоимости +- **WHEN** у ненулевой позиции отсутствует либо невалидна цена, количество или валюта цены +- **THEN** система возвращает позицию с `currentValue: null` и не включает её в `totalsByCurrency` + +#### Scenario: Отрицательная дробная часть upstream-значения +- **WHEN** upstream возвращает сумму с `units: 0` и отрицательной дробной частью `nano: -500000000` +- **THEN** система передаёт её как строку `"-0.5"` + +### Requirement: Свежесть и fallback полного Portfolio Snapshot +Система MUST кэшировать полный успешный Portfolio Snapshot отдельно для каждой пары User и Brokerage Account в памяти на 30 секунд. После истечения свежего кэша система MUST получить новый полный снимок; при временной upstream-недоступности она SHALL вернуть последний успешный полный снимок, если ему не более пяти минут, с `isStale: true`. Metadata инструмента MUST NOT влиять на успешность или свежесть Portfolio Snapshot, но MUST быть получено до формирования нового Snapshot, чтобы при доступном upstream-ответе вернуть отображаемое имя инструмента. + +Система MUST очищать все Portfolio Snapshot User после успешной замены или удаления его T-Bank connection. Успешные metadata инструмента и подтверждённое отсутствие инструмента MUST кэшироваться глобально по FIGI на 24 часа; ошибка metadata lookup MUST NOT кэшироваться. + +#### Scenario: Свежий Snapshot +- **WHEN** User повторно запрашивает тот же Portfolio в течение 30 секунд после успешного получения +- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank + +#### Scenario: Медленный lookup инструмента +- **WHEN** metadata инструмента ещё отсутствует и его lookup не завершился +- **THEN** система ожидает lookup в пределах gRPC deadline и возвращает name при успешном ответе upstream + +#### 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` + +### Requirement: Ошибки и состояния Portfolio на клиенте +Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд, без automatic retry, polling или refetch при возврате фокуса. После истечения срока клиент MUST скрыть предыдущий ответ и показать loading-состояние до нового результата. + +Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки. + +Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. + +#### Scenario: Показ устаревшего Portfolio +- **WHEN** клиент получает успешный ответ с `isStale: true` +- **THEN** клиент показывает позиции и остатки, время последнего успешного снимка и предупреждение без действия ручного обновления + +#### Scenario: Недоступный счёт на клиенте +- **WHEN** клиент получает `404` для Portfolio +- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора + +#### Scenario: Портфель отображается на мобильном устройстве +- **WHEN** User открывает загруженный Portfolio на узком экране +- **THEN** клиент сохраняет читаемое одноколоночное отображение агрегатов, позиций и Cash Balance + +#### Scenario: Отдельные акции и облигации +- **WHEN** Portfolio содержит позиции `STOCK` и `BOND` +- **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции diff --git a/openspec/changes/archive/2026-08-13-add-portfolio-view/tasks.md b/openspec/changes/archive/2026-08-13-add-portfolio-view/tasks.md new file mode 100644 index 0000000..76856e7 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-portfolio-view/tasks.md @@ -0,0 +1,32 @@ +## 1. Backend Portfolio read-path + +- [x] 1.1 Расширить существующий T-Bank client вызовами `GetPortfolio`, `GetPositions` и FIGI metadata lookup, сохранив TLS, global limiter, deadline, retry и безопасный mapping gRPC ошибок. +- [x] 1.2 Добавить точный normalizer на `BigInt` для protobuf `units`/`nano`: canonical decimal strings, signed дробные части, сложение, вычитание и умножение без JavaScript float. +- [x] 1.3 Добавить Portfolio DTO, authenticated controller и user-scoped service для `GET /api/v1/brokerage-accounts/:accountId/portfolio`; проверять доступный Brokerage Account через `GetAccounts`, затем получать `GetPortfolio` и `GetPositions` параллельно. +- [x] 1.4 Нормализовать обычные ненулевые позиции из `GetPortfolio.positions`, доступные ненулевые Cash Balance из `GetPositions.money`, типы инструментов, FIGI/ticker/name fallback, `currentValue`, `absoluteReturn` и totals по валютам без конвертации. +- [x] 1.5 Добавить in-memory Portfolio Snapshot cache с fresh TTL 30 секунд, fallback до пяти минут только для временной недоступности и очисткой User snapshots после replace/delete T-Bank connection. +- [x] 1.6 Добавить глобальный FIGI metadata cache на 24 часа для успешных и отрицательных результатов; ошибки lookup не кэшировать и не делать их причиной ошибки Portfolio Snapshot. +- [x] 1.7 Добавить backend unit/e2e тесты: auth, отсутствие connection, `404` недоступного счёта, error mapping, точные отрицательные decimal values, empty Portfolio, порядок, валютные агрегаты, metadata fallback/cache, snapshot fresh/fallback/invalid token и invalidation connection. + +## 2. Generated contract and frontend data layer + +- [x] 2.1 Запустить backend API и перегенерировать frontend OpenAPI client; проверить generated contract для Portfolio endpoint и DTO. +- [x] 2.2 Добавить FSD entity slice `portfolio` с API query через generated `paths[...]`, `staleTime: 30_000`, отключённым retry, обработкой codes и public barrel export. +- [x] 2.3 Добавить локальные display helpers для русской локали, которые форматируют decimal strings и валюты без финансовой арифметики на клиенте. + +## 3. Portfolio detail page + +- [x] 3.1 Заменить placeholder `/accounts/:accountId` на loading, empty, unavailable-account, known-error, generic-error и loaded state-вьюхи, скрывая устаревший frontend Snapshot при refetch. +- [x] 3.2 Добавить loaded Portfolio view с названием/типом Brokerage Account из существующего account-list cache или нейтральным заголовком при прямом URL, агрегатами по валютам, upstream-порядком позиций и отдельной секцией Cash Balance. +- [x] 3.3 Показать `lastSuccessfulAt` и нейтральное stale warning без ручного обновления; добавить действия возврата к списку, настройкам или retry только в разрешённых error-состояниях. +- [x] 3.4 Оформить detail page локальными Mantine/CSS-module компонентами в светлом финансовом стиле `references/fin-tracker`: slate-границы, синий accent, плотная иерархия сумм и одноколоночный mobile layout, не копируя reference code и не добавляя placeholder-данные. +- [x] 3.5 Добавить frontend tests для query policy, всех page states, stale warning, error actions, account-title fallback, upstream порядка и rendering nullable значений. + +## 4. Documentation and verification + +- [x] 4.1 Создать `docs/features/portfolio-view.md` с пользовательским потоком, Mermaid sequence diagram, API contract, публичной моделью, exact decimal rules, cache/fallback, metadata fallback, ошибками и MVP-ограничениями. +- [x] 4.2 Сверить ADR 005 с фактической общей cache policy и обновить только при противоречии. +- [x] 4.5 Отображать позиции раздельно по типам, предпочитать полное name инструмента и форматировать RUB символом `₽`. +- [x] 4.6 Искать metadata инструмента по UID с fallback на FIGI, чтобы уменьшить технические displayName. +- [x] 4.3 Запустить backend lint, tests и build; запустить frontend lint, typecheck, tests, build и no-TODO check; визуально проверить desktop и mobile состояния Portfolio. +- [x] 4.4 Перед архивированием запустить `openspec validate add-portfolio-view --type change --strict --store wealth-ledger-specs` и `opsx-verify`. diff --git a/openspec/specs/portfolio-view/spec.md b/openspec/specs/portfolio-view/spec.md new file mode 100644 index 0000000..192af80 --- /dev/null +++ b/openspec/specs/portfolio-view/spec.md @@ -0,0 +1,108 @@ +# portfolio-view Specification + +## Purpose + +Позволяет авторизованному User просматривать нормализованный текущий состав и агрегаты Portfolio одного доступного Brokerage Account без валютной конвертации. + +## Requirements + +### Requirement: Получение Portfolio выбранного Brokerage Account +Система SHALL предоставлять авторизованному User `GET /api/v1/brokerage-accounts/:accountId/portfolio`. Она MUST возвращать нормализованный полный Portfolio только для открытого поддерживаемого Brokerage Account, доступного через текущий T-Bank connection User. Ответ MUST содержать `lastSuccessfulAt` в RFC 3339 UTC без миллисекунд, boolean `isStale`, `totalsByCurrency`, `positions` и `cashBalances`. + +#### Scenario: Получение текущего Portfolio +- **WHEN** User запрашивает Portfolio доступного Brokerage Account, а обязательные upstream-данные успешно получены +- **THEN** система отвечает `200` полным нормализованным Portfolio с `isStale: false` и временем успешного получения финансового снимка + +#### Scenario: Недоступный Brokerage Account +- **WHEN** `accountId` не принадлежит User, закрыт, неподдерживаем или больше не доступен через текущий T-Bank connection +- **THEN** система отвечает `404` без Portfolio + +### Requirement: Нормализованный состав Portfolio +Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок 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`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения. + +#### Scenario: Позиция с неизвестным upstream-типом +- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента +- **THEN** система возвращает её в исходном порядке с типом `OTHER` + +#### 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: Денежные остатки в одной валюте +- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты +- **THEN** система возвращает один Cash Balance с их точной суммой + +#### Scenario: Штатный пустой Portfolio +- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков +- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency` + +### Requirement: Точные значения и валютные агрегаты +Все количества и финансовые значения в Portfolio MUST передаваться десятичными строками без экспоненциальной записи и без незначащих завершающих нулей. Денежное значение MUST содержать ISO-код валюты. Система MUST корректно нормализовать отрицательные целую и дробную части upstream-значений. Отсутствующее значение MUST передаваться как `null`, а не как ноль. + +`currentValue` Portfolio Position MUST быть точным произведением количества на текущую цену при наличии валидных множителей и валюты цены. `absoluteReturn` MUST быть точной величиной `(currentPrice - averagePurchasePrice) * quantity` только когда обе цены имеют одинаковую известную валюту. Позиция без вычислимой текущей стоимости MUST оставаться в ответе, но MUST NOT входить в агрегаты. `totalsByCurrency` MUST содержать точную сумму currentValue позиций и Cash Balance в каждой валюте без валютной конвертации. + +#### Scenario: Мультивалютный Portfolio +- **WHEN** Portfolio содержит финансовые значения в RUB и USD +- **THEN** система возвращает отдельные агрегаты RUB и USD без общего сконвертированного итога + +#### Scenario: Позиция без вычислимой стоимости +- **WHEN** у ненулевой позиции отсутствует либо невалидна цена, количество или валюта цены +- **THEN** система возвращает позицию с `currentValue: null` и не включает её в `totalsByCurrency` + +#### Scenario: Отрицательная дробная часть upstream-значения +- **WHEN** upstream возвращает сумму с `units: 0` и отрицательной дробной частью `nano: -500000000` +- **THEN** система передаёт её как строку `"-0.5"` + +### Requirement: Свежесть и fallback полного Portfolio Snapshot +Система MUST кэшировать полный успешный Portfolio Snapshot отдельно для каждой пары User и Brokerage Account в памяти на 30 секунд. После истечения свежего кэша система MUST получить новый полный снимок; при временной upstream-недоступности она SHALL вернуть последний успешный полный снимок, если ему не более пяти минут, с `isStale: true`. Metadata инструмента MUST NOT влиять на успешность или свежесть Portfolio Snapshot, но MUST быть получено до формирования нового Snapshot, чтобы при доступном upstream-ответе вернуть отображаемое имя инструмента. + +Система MUST очищать все Portfolio Snapshot User после успешной замены или удаления его T-Bank connection. Успешные metadata инструмента и подтверждённое отсутствие инструмента MUST кэшироваться глобально по FIGI на 24 часа; ошибка metadata lookup MUST NOT кэшироваться. + +#### Scenario: Свежий Snapshot +- **WHEN** User повторно запрашивает тот же Portfolio в течение 30 секунд после успешного получения +- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank + +#### Scenario: Медленный lookup инструмента +- **WHEN** metadata инструмента ещё отсутствует и его lookup не завершился +- **THEN** система ожидает lookup в пределах gRPC deadline и возвращает name при успешном ответе upstream + +#### 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` + +### Requirement: Ошибки и состояния Portfolio на клиенте +Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд, без automatic retry, polling или refetch при возврате фокуса. После истечения срока клиент MUST скрыть предыдущий ответ и показать loading-состояние до нового результата. + +Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки. + +Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. + +#### Scenario: Показ устаревшего Portfolio +- **WHEN** клиент получает успешный ответ с `isStale: true` +- **THEN** клиент показывает позиции и остатки, время последнего успешного снимка и предупреждение без действия ручного обновления + +#### Scenario: Недоступный счёт на клиенте +- **WHEN** клиент получает `404` для Portfolio +- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора + +#### Scenario: Портфель отображается на мобильном устройстве +- **WHEN** User открывает загруженный Portfolio на узком экране +- **THEN** клиент сохраняет читаемое одноколоночное отображение агрегатов, позиций и Cash Balance + +#### Scenario: Отдельные акции и облигации +- **WHEN** Portfolio содержит позиции `STOCK` и `BOND` +- **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции