spec: plan account concentration tab (WealthLedger/main#48)
This commit is contained in:
@@ -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 требует две строки, точную сумму без обрезки и текстовый эквивалент |
|
||||
Reference in New Issue
Block a user