# Системный анализ ## Контекст и статус Цель — передать в 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` — `{t("investment.partial")}` при `props.partial`; признак вычисляется как `portfolio.meta?.completeness === "incomplete"` (`frontend/src/pages/account/ui/AccountPage/AccountPage.vm.ts:124,130`); - Alert выводится над `` (`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` |