refactor: group canonical specs by domain and archive account-top-positions (#74)
This commit was merged in pull request #75.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: wealth-ledger-sdd-lab
|
||||
created: 2026-10-30
|
||||
@@ -0,0 +1,145 @@
|
||||
# Маппинг данных
|
||||
|
||||
## Источник и цель
|
||||
|
||||
Документ не выбирает внутренний способ загрузки и хранения. Обозначения **PC**, **PVΔ** и правило нумерации строк — в [system-analysis.md](system-analysis.md). Requirements: `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-concentration/spec.md` (28) и `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-view/spec.md` (4). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). Исходы получения (когда поле заменяется, сохраняется или становится `null`, когда отсчёт 24 часов начинается заново) определяет таблица PVΔ:156; здесь она не пересказывается.
|
||||
|
||||
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M1: сектор | `sector` Share / Bond / Etf (T-Bank) | `positions[].sector` (backend) | T-Bank → хранимое значение → HTTP Portfolio | Часовой обход по условию PVΔ:141 |
|
||||
| M2: эмитент | `asset_uid` инструмента → `AssetFull.brand.uid` / `name` (T-Bank) | `positions[].issuer` (backend) | T-Bank → хранимое значение → HTTP Portfolio | То же |
|
||||
| M3: проекция | Полный ответ Portfolio (backend — владелец API) | Разрезы, суммы, доли, подписи, имя эмитента (frontend) | HTTP Portfolio → вычисляемая проекция | Получение ответа, смена разреза или валюты; обращений к T-Bank нет |
|
||||
|
||||
Финансовый источник не меняется: количество и цена из GetPortfolio, деньги из GetPositions; sector и issuer деньги не пересчитывают (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`; `backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:33-55,63-98`).
|
||||
|
||||
### Источники-кандидаты в T-Bank
|
||||
|
||||
Выбор между точечным запросом и пригодными ранее сохранёнными данными — design: Specs его не определяют. Сопоставление идёт по instrument UID, затем по FIGI (PVΔ:3); эмитент по названию, тикеру или ISIN не определяется.
|
||||
|
||||
| Потребность | Кандидат и поля ответа | Подтверждение / ограничения |
|
||||
| --- | --- | --- |
|
||||
| STOCK: sector, asset UID | `ShareBy(InstrumentRequest)` → `Share.sector`, `Share.asset_uid` | RPC `backend/src/integrations/tbank/proto/contracts/instruments.proto:65`; поля `:760,:776` |
|
||||
| BOND: sector, asset UID | `BondBy(InstrumentRequest)` → `Bond.sector`, `Bond.asset_uid` | RPC `instruments.proto:24`; поля `:525,:543` |
|
||||
| FUND: sector, asset UID | `EtfBy(InstrumentRequest)` → `Etf.sector`, `Etf.asset_uid` | RPC `instruments.proto:42`; поля `:644,:657` |
|
||||
| Общая идентичность | `GetInstrumentBy` → `Instrument.uid`, `figi`, `asset_uid` | `asset_uid` есть (`instruments.proto:971`); поля `sector` в `Instrument` нет, поэтому им одним сектор не получить |
|
||||
| Бренд актива | `GetAssetBy(AssetRequest{id: asset_uid})` → `AssetFull.brand.uid`, `brand.name` | `instruments.proto:1030-1036,1051-1068,1220-1229`; `asset_uid` — ключ запроса, не значение `issuer.id` |
|
||||
| Список активов | `GetAssets` → `Asset[]` | У `Asset` нет `brand` (`instruments.proto:1075-1080`); для `issuer` не подходит |
|
||||
|
||||
### Что уже есть в коде
|
||||
|
||||
- GetInstrumentBy по UID и FIGI даёт общую metadata. Его `brand` — `BrandData` (`logo_name`, цвета), не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/tbank.client.ts:293-305,493-509`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Эмитента из `logoName` или названия инструмента получить нельзя.
|
||||
- Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; типы `TbankShare`/`TbankBond` их содержат (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:341-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23,48-61`). Это отдельный источник, и пользователь не должен сначала открывать карточку инструмента.
|
||||
- `GetAssetBy` уже вызывается ради `description`; `brand` из ответа не передаётся (`backend/src/integrations/tbank/tbank.client.ts:420-429`; `backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:431-437`). `GetAssets` в TypeScript интеграции не используется.
|
||||
- **Расхождения AS-IS с нормами, которые закрывает реализация** (это требования, а не выбор):
|
||||
- запись общей metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`) — норма PVΔ:141 требует срок от последнего успешного получения;
|
||||
- клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`) — по PVΔ:156 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».
|
||||
|
||||
## Маппинг полей
|
||||
|
||||
В колонке «При ошибке» исход определяется таблицей PVΔ:156; «не найдено» и «значения нет» — один исход. Колонка не повторяет решений таблицы.
|
||||
|
||||
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `position.sector` | `Share.sector` / `Bond.sector` / `Etf.sector` по типу STOCK / BOND / FUND | Обрезать пробелы по краям; пусто или только пробелы → «значения нет»; остальное как получено, без lowercase, перевода и классификации | Код, без валюты | string или null | `null` | Строка; ответ принадлежит запрошенному инструменту | По PVΔ:156; `issuer` и финансы не затрагиваются |
|
||||
| `position.sector` у остальных типов | — | Всегда `null`, получение не нужно | — | `null` | `null` | Тип позиции из нормализации | — |
|
||||
| `position.issuer.id` | `AssetFull.brand.uid` | Обрезать пробелы; перенос без перекодирования | Непрозрачный идентификатор | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки; `AssetFull.uid` совпадает с запрошенным `asset_uid` | По PVΔ:156 |
|
||||
| `position.issuer.name` | `AssetFull.brand.name` | Обрезать пробелы; не `brand.company`, не `asset.name` | Текст | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки | По PVΔ:156 |
|
||||
| `position.issuer` | Предыдущие две строки одного ответа | `{id, name}` только если оба пригодны, иначе «значения нет»; STOCK / BOND / FUND, включая FUND; прочие типы `null` | Объект | object или null | `null` | Частичный объект запрещён | По PVΔ:156, объект целиком |
|
||||
| Время последнего успешного получения | Исход получения | Меняется и не меняется по таблице PVΔ:156; читается условием PVΔ:141; ведётся отдельно для `sector` и `issuer` (исходы двух запросов могут различаться) | Момент времени | Нет значения до первого успешного получения | Нет | — | — |
|
||||
| Новые ключи старого Snapshot | Отсутствуют | Отдавать как `null` | — | как в контракте | `null` | Отличать от отсутствия полного Portfolio | Snapshot не отбрасывать |
|
||||
| Название сектора | `position.sector` | Точное совпадение с PC:374 → подпись; иное непустое значение → как получено; `null` → «Сектор не указан» | Текст отображения | Название непустое | Группа «Сектор не указан» | Ключ группировки — код, не подпись | Неизвестный код не объединять с `null` |
|
||||
| Название эмитента | `issuer.name` позиции с наибольшим `currentValue` среди учитываемых позиций одного `issuer.id` в выбранной валюте | Группировать по `issuer.id`; при равных наибольших значениях — меньшее название по алфавитному порядку PC:274; `null` → «Эмитент не указан» | Текст отображения | Название непустое | Группа «Эмитент не указан» | Одинаковое `name` при разных `id` не объединяет | Нет |
|
||||
| Сумма элемента / разреза | `currentValue`, `type`, `sector`, `issuer.id` | Отбор → сумма по ключу; без округления | Десятичная строка Money, одна валюта | Для учтённых элементов | Пустое состояние | Не смешивать валюты | Непригодные sector/issuer меняют группу, не сумму |
|
||||
| Доля элемента, метрика топ-5 | Сумма элемента, сумма разреза | Точное отношение; метрика — сумма точных долей первых пяти; округление только при показе | Доля → % | Нет при пустом разрезе | Пустое состояние | Знаменатель > 0 | Не делить на ноль, не выдавать NaN |
|
||||
|
||||
**Нормализация строк.** Источник правил — PVΔ:3: сектор «как определяет T-Bank, без перекодирования», края обрезаются, значение из одних пробелов отсутствует. Регистр не меняется: `energy` и `Energy` — разные коды (PC:364 запрещает объединять разные `sector`). Для `issuer.id` Specs требуют непустоту после обрезки; обрезка возвращаемого `id` следует решению 3081 «Пробелы обрезать» и нужна, чтобы идентификатор не расщеплял группу.
|
||||
|
||||
**Что считается «значения нет».** Таблица PVΔ:156 использует понятие, но не перечисляет признаки; mapping задаёт их для полей этого документа: нет поля `sector`, `asset_uid`, `brand` в ответе; пустая строка либо только пробелы после обрезки; пара `brand.uid` / `brand.name` непригодна. Ответ о другом инструменте или активе распознаётся несовпадением UID / FIGI / `asset_uid` с запросом (проверка UID в AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:141-160`).
|
||||
|
||||
**Подписи секторов.** Нормативный справочник — PC:374: consumer, energy, financial, health_care, industrials, it, materials, real_estate, telecom, utilities, government. Это только отображение; `Brand.sector` как источник не используется. `other` остаётся `other` (PC:364).
|
||||
|
||||
### Применимость по типу позиции
|
||||
|
||||
| Portfolio type | `sector` | `issuer` | Участие в разрезах |
|
||||
| --- | --- | --- | --- |
|
||||
| STOCK | `Share.sector` | `AssetFull.brand` или `null` | Акции, Секторы, Эмитенты |
|
||||
| BOND | `Bond.sector` | `AssetFull.brand` или `null` | Облигации, Секторы, Эмитенты |
|
||||
| FUND | `Etf.sector` | `AssetFull.brand` или `null`; **заполняется** (PVΔ:3, решение 3081) | Только Секторы; разрез «Эмитенты» фонд отбрасывает (PC:69) |
|
||||
| CURRENCY, FUTURE, OPTION, STRUCTURED_NOTE, DIGITAL_FINANCIAL_ASSET, OTHER | Всегда `null` | Всегда `null` | Ни в одном из четырёх разрезов |
|
||||
|
||||
Типы share → STOCK, bond → BOND, etf → FUND нормализуются mapper'ом (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31`); sector и issuer тип не меняют.
|
||||
|
||||
## Инварианты
|
||||
|
||||
| ID | Правило | Защищаемое Requirement |
|
||||
| --- | --- | --- |
|
||||
| I1 | Обогащение не удаляет, не объединяет и не переставляет позиции; одинаковый FIGI не означает дубликат | PVΔ:3 |
|
||||
| I2 | quantity, `currentValue`, `averagePurchasePrice`, `absoluteReturn`, `cashBalances` и агрегаты не зависят от sector/issuer; `null` не делает стоимость неизвестной. Исключение — отказ token (PVΔ:104) | PVΔ:3; PC:484 |
|
||||
| I3 | В одном разрезе позиция участвует не более одного раза; фонд попадает в «Секторы», но не в «Акции», «Облигации», «Эмитенты» | PC:69 |
|
||||
| I4 | Учитываются только позиции с `currentValue` > 0 в выбранной валюте; отрицательные и неоценённые исключаются, не берутся по модулю | PC:129 |
|
||||
| I5 | Знаменатель = сумма учитываемых позиций разреза в выбранной валюте = сумма элементов; Cash Balance и `totalsByCurrency` не участвуют; `null`-группа входит в сумму | PC:289, PC:349 |
|
||||
| I6 | «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными, не подменяются кодом T-Bank и не сливаются с реальным значением | PC:349 |
|
||||
| I7 | До 6 элементов — все строки; от 7 — пять и «Прочее» (сумма остальных, число остальных); метрика «доля топ-5» — только при ≥ 6 элементах | PC:229, PC:324 |
|
||||
| I8 | Порядок: сумма по убыванию; при равенстве — название (цифры, латиница, кириллица), затем порядок первой позиции. Название эмитента — по позиции с наибольшим `currentValue`, при равных — меньшее по алфавиту; сумму и группировку оно не меняет | PC:274, PC:114 |
|
||||
| I9 | Точные доли дают ровно 100 %; «Прочее» = сумма долей остальных; округление только при показе; сумма показанных долей может не быть 100 % | PC:289, PC:304 |
|
||||
| I10 | Список, диаграмма и центр используют одни суммы; раскрытие «Прочее» не меняет диаграмму; в центре — сумма разреза и, при метрике, «Топ-5 · N %» | PC:254, PC:402, PC:415 |
|
||||
| I11 | Валюты вкладки — ненулевые оценки STOCK/BOND/FUND (включая отрицательные), по ISO; выбора нет при ≤ 1 валюте | PC:169, PC:184 |
|
||||
| I12 | N сноски = позиции разреза с `currentValue` `null` в любой валюте + отрицательные в выбранной; нулевые и отрицательные другой валюты не считаются; `null` sector/issuer не считается | PC:144 |
|
||||
| I13 | Название — представление, не идентификатор: разные `issuer.id` и разные `sector` не объединяются при одинаковой подписи | PC:96, PC:364 |
|
||||
| I14 | Нет учтённых элементов — пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | PC:451 |
|
||||
| I15 | `sector` и `issuer` отличны от `null` только у STOCK/BOND/FUND; значения обрезаны; `issuer` — пара целиком | PVΔ:3 |
|
||||
| I16 | Поле и отсчёт 24 часов после получения определяются только таблицей PVΔ:156: ни один исход, кроме строк «Получено значение» и «Ответ „не найдено“ или значения нет», не меняет значение и не сдвигает отсчёт | PVΔ:156, PVΔ:141 |
|
||||
|
||||
Доля может быть периодической дробью. Требование 100 % относится к точным суммам, а не к сумме показанных процентов; последнюю долю подгонять нельзя. Текущий frontend helper использует `Number` (`frontend/src/entities/portfolio/model/display.ts:7-24`): он задаёт стиль показа, но не доказывает достаточную точность. Способ точного расчёта — design.
|
||||
|
||||
## Риски двойного учёта и классификации
|
||||
|
||||
| Ситуация | Риск | Правило |
|
||||
| --- | --- | --- |
|
||||
| Обыкновенная акция, привилегированная акция и облигации с одним `brand.uid` | Разные компании либо слияние самих позиций | В «Эмитентах» суммируется один `id`; в «Акциях» и «Облигациях» позиции остаются отдельными |
|
||||
| Одна компания под разными брендами | Занижение концентрации | Разные `id` не объединять; эмитент — бренд T-Bank, а не юридическое лицо. Покрытие — Q-D1 |
|
||||
| Разные бренды с одинаковым `name` | Слияние по подписи | Два элемента (PC:96) |
|
||||
| Один `brand.uid`, разные `name` у позиций | Неоднозначное имя | Имя позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавиту (PC:114) |
|
||||
| Фонд с тем же `brand.uid`, что у акции | Повторный учёт или учёт фонда как компании | `issuer` фонда есть в API, но «Эмитенты» его отбрасывают; в сумму эмитента фонд не входит |
|
||||
| Фонд с `sector` | Раскладка состава фонда | Используется только `Etf.sector` позиции; состав, `focus_type`, название и управляющая компания не используются |
|
||||
| Один бренд, разные сектора у акции и облигации | Подмена сектора эмитента | Каждая позиция в своём `sector`; сектор из `issuer` не выводится |
|
||||
| Государственная облигация | Классификация по типу или названию | «Государственный сектор» только при `sector` = `government` |
|
||||
| Cash, `CURRENCY` и денежный фонд | Двойной учёт денег | Cash и `CURRENCY` вне концентрации; фонд — по общим правилам |
|
||||
| Повторяющийся FIGI | Дедупликация настоящих строк | Позиции не удаляются; повтор строки upstream и повтор доставки lookup — разные явления |
|
||||
| Одна позиция в четырёх разрезах | Общий итог разрезов | Разрезы независимы, между ними не складывать |
|
||||
| Раскрытое «Прочее» | Двойной учёт остатка | Детализация той же суммы, не новые сегменты |
|
||||
|
||||
## Ошибки преобразования
|
||||
|
||||
| Ошибка | Распознавание | Данные |
|
||||
| --- | --- | --- |
|
||||
| Нет надёжного UID/FIGI | Отсутствие ключа | Значение не присваивается; поле остаётся прежним или `null` |
|
||||
| Ответ другого инструмента или актива | Несовпадение UID / FIGI / `asset_uid` с запросом | Исход «ответ о другом инструменте или активе» по PVΔ:156 |
|
||||
| Пустой / только пробелы `sector` | Результат обрезки | Признак «значения нет» |
|
||||
| Новый неизвестный непустой `sector` | Вне таблицы PC:374 | Не ошибка: код сохраняется и показывается как есть |
|
||||
| Нет `asset_uid`, актива или бренда | Отсутствие звена цепочки | Признак «значения нет» |
|
||||
| Пустые `brand.uid` или `brand.name` после обрезки | Непригодная пара | Признак «значения нет» для `issuer` целиком |
|
||||
| `Brand.sector` использован вместо `Share/Bond/Etf.sector` | Нарушение M1 | Ошибка маппинга, даже если значения совпадают |
|
||||
| Приведение кода к lowercase, перевод кода при выдаче | Изменённый ключ | Ошибка контракта; перевод только в подписи frontend |
|
||||
| Старый JSON без ключей | Ключей нет | `null` на HTTP-границе |
|
||||
| Потеря точности суммы, доля после округления | Нарушение I5, I9 | Округлённые подписи не служат источником данных |
|
||||
| Тип вне STOCK/BOND/FUND имеет `sector`/`issuer` | Нарушение I15 | Принудительно `null` |
|
||||
|
||||
Сетевые ошибки, повторы, сроки и восстановление — [failure-model.md](failure-model.md).
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
|
||||
|
||||
- Пробелы — края обрезаются, значение из одних пробелов отсутствует (PVΔ:3; 3081).
|
||||
- `issuer` у фонда — заполняется для STOCK/BOND/FUND (PVΔ:3), разрез фонд отбрасывает (PC:69) (3081).
|
||||
- Разные названия одного бренда — название позиции с наибольшим `currentValue`; при равных — по алфавиту (PC:114; 3081, 3082).
|
||||
- «Не найдено» от T-Bank — как «значения нет» (PVΔ:156; 3082).
|
||||
- Исключение из правила «справочные сведения онлайн» для сектора и бренда (PVΔ:104; 3082).
|
||||
|
||||
Остаётся неизвестным. Это факты данных, они проверяются при реализации и решения владельца не требуют.
|
||||
|
||||
| ID | Что неизвестно | Чем проверяется |
|
||||
| --- | --- | --- |
|
||||
| Q-D1 | Доля позиций STOCK / BOND / FUND, у которых есть пригодная пара `brand.uid` / `brand.name`; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | Одноразовая read-only проверка на реальном Portfolio владельца в начале реализации: доля позиций с непустой парой и таблица «эмитент → типы бумаг». Низкое покрытие scope не меняет (`proposal.md:17`), но определяет, как будет выглядеть разрез на реальных данных |
|
||||
| Q-D2 | Какие `sector` реально отдаются для фондов и государственных облигаций | Та же проверка: список различных значений `sector` по типам со счётчиками; сверка с таблицей PC:374. Неизвестные коды обрабатываются по PC:364 и решения не требуют |
|
||||
@@ -0,0 +1,143 @@
|
||||
# Documentation Impact
|
||||
|
||||
## Summary
|
||||
|
||||
Change `account-top-positions` (Issue [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48)) добавляет на страницу одного счёта вкладку «Концентрация» с разрезами «Акции», «Облигации», «Секторы», «Эмитенты», а в позиции Portfolio — сектор и эмитента (бренд актива). Для docs это значит:
|
||||
|
||||
- В AS-IS появляются новые понятия и правила: вкладка, разрезы, «Прочее», сектор, эмитент (бренд актива во вкладке), правило 24 часов для сектора и бренда.
|
||||
- Сектор и бренд — исключение из правила «справочные сведения только онлайн» ([ADR 019](../../../../../docs/adr/019-hourly-reads-and-online-instrument-reference.md)). Решение оформляется новым [ADR 022](../../../../../docs/adr/022-stored-instrument-classification.md); текст ADR 019 не переписывается, в его статус добавляется ссылка.
|
||||
- Хранение решено в design (B1): отдельная глобальная таблица `TbankInstrumentClassification`, не связанная с подключением. Её состав и поведение при отказах отражаются в `containers.md` и `data-freshness.md`.
|
||||
- Правки AS-IS — после реализации и её слияния в `develop`. До этого docs не описывает поведение, которого в системе нет.
|
||||
|
||||
Пути ниже — относительно корня workspace `WealthLedger/`; для docs-репозитория полный путь — `docs/<path>`.
|
||||
|
||||
## Documentation Changes
|
||||
|
||||
| Action | Target | Category | Итоговое AS-IS | Source | Verification |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Изменить | `docs/domains/portfolio/overview.md`, разделы «Снимок и связанные области» и «Как читать экран» | Domain | На странице одного счёта рядом с «Позиции» есть вкладка «Концентрация». В ней четыре разреза: «Акции», «Облигации», «Секторы», «Эмитенты». По умолчанию открыт «Акции» и первая по ISO валюта. В каждом разрезе топ-5 элементов; при семи и более элементах остальные — в «Прочее», раскрытие не меняет топ-5. Суммы и доли — в одной валюте, конвертации нет. Вкладки нет в режиме «Все счета» и при загрузке, ошибке или пустом Portfolio. Вкладка не меняет вкладку по умолчанию. Деньги не входят в знаменатель долей (в отличие от «Структуры») | `specs/.../portfolio-concentration/spec.md` (Вкладка, Состояния, Переключение, Начальное состояние, Одна валюта); `proposal.md` What Changes; `design.md` D8–D10 | Сверка с мерджнутым frontend и итоговыми Specs; проверка ссылок |
|
||||
| Добавить | `docs/domains/portfolio/concentration.md` (новый файл, если правила не умещаются в `overview.md`; решение при написании) | Domain / reference | Состав разрезов по типам позиций; топ-5 и «Прочее»; группы «Сектор не указан», «Эмитент не указан»; значение `government` с подписью «Государственный сектор»; правила долей и одной валюты | `specs/.../portfolio-concentration/spec.md` (Состав разрезов, Названия элементов); `proposal.md` | Линк из `overview.md` и `docs/README.md`; markdownlint |
|
||||
| Изменить | `docs/domains/portfolio/data-reference.md`, таблица «Состав и денежные показатели»; раздел «Структура и доли» | Reference | Позиция получает `sector` (сектор T-Bank без перекодирования, пробелы обрезаны, пустое → `null`; только `STOCK`, `BOND`, `FUND`) и `issuer` (бренд актива T-Bank; `null` вне разрезов; фонды в разрез «Эмитенты» не входят). `logoName` не меняется. В «Структуре» отметить: знаменатель и валютные итоги включают деньги, вкладка «Концентрация» — нет | `specs/.../portfolio-view/spec.md` «Нормализованный состав Portfolio» (PVΔ:3); `data-mapping.md` M1, M2; `design.md` D7 | Сверка с кодом ответа и с OpenAPI, выпущенным backend; DTO не копируется |
|
||||
| Изменить | `docs/domains/portfolio/flows/portfolio-loading.md`, разделы «Основной обмен» и «Ошибки и результат для пользователя» | Flow | Сектор и бренд получаются в том же часовом обходе, в шаге до публикации Snapshot: после GetPortfolio и GetPositions. Первая попытка по позиции — не раньше чем через 24 часа после последнего успешного получения; пока значение не получено, попытка повторяется при каждом обходе. Ошибка получения не мешает публикации Snapshot и не меняет `isStale` и `lastSuccessfulAt`, кроме отказа token: он даёт `400 INVALID_TBANK_TOKEN` без fallback. Сохранённое значение остаётся доступным. Обычный GET по-прежнему не обращается к T-Bank. Фраза «Metadata инструмента дополняет представление независимо» уточняется: сектор и эмитент входят в Snapshot | PVΔ:104, PVΔ:141, PVΔ:156; `failure-model.md`; `design.md` B2, D1–D5 | Сверка с таблицей исходов PVΔ:156 и с кодом после слияния |
|
||||
| Изменить | `docs/integrations/tbank.md`, таблица «Данные и ответственность»; раздел «Публичные логотипы» | Integration | Новые данные из T-Bank: сектор инструментов акций, облигаций и фондов (вызовы `ShareBy`, `BondBy`, `EtfBy`); бренд актива (uid и название) через `GetAssetBy` по `asset_uid`. Эмитент не выводится из названия, тикера или ISIN. Логотип по-прежнему приходит из metadata инструмента (`logoName`) и не равен эмитенту. Вызовы проходят через общий лимитер (по умолчанию 5/с) и ограниченную очередь | `data-mapping.md` «Источники-кандидаты в T-Bank»; `design.md` B4, D6 | Сверка с кодом T-Bank-клиента после слияния; ссылки на CDN не меняются |
|
||||
| Изменить | `docs/architecture/cross-cutting/data-freshness.md`, раздел «Клиентский и серверный снимки» (список данных SQLite) и абзац про часовой обход | Architecture (cross-cutting) | SQLite дополнительно хранит классификацию инструментов (сектор и бренд) в глобальной таблице `TbankInstrumentClassification`: она не принадлежит пользователю или подключению и не ограждается по connection revision. Каждое поле имеет свой возраст (`sectorFetchedAt`, `brandFetchedAt`). Классификация не входит в `meta.dependencies` и не меняет свежесть Snapshot. Прежнее утверждение «общие сведения об инструментах запрашиваются онлайн» получает оговорку про это исключение и ссылку на ADR 022 | `design.md` Data / Storage Changes, D1, D4; `design.md` Cross-cutting «Freshness»; PVΔ:104, PVΔ:3 | Сверка с миграцией Prisma после слияния; проверка ссылки на ADR 022 |
|
||||
| Изменить | `docs/architecture/containers.md`, раздел «Хранимые данные и границы доступа» (абзац про portfolio и общие сведения) | Architecture (data) | Добавить: SQLite содержит таблицу `TbankInstrumentClassification` (провайдер, ключ инструмента, сектор с возрастом, uid актива и бренда с именем и возрастом). Данные публичные, без токена и позиций. Снимок Portfolio полей классификации отдельно не хранит: поля `sector` и `issuer` позиций заполняются из таблицы при публикации | `design.md` Data / Storage Changes; B2; PVΔ «Ограничения доступа» | Сверка с `schema.prisma` и миграцией после слияния |
|
||||
| Изменить | `docs/glossary.md`, блок Portfolio (после «Currency Instrument Position», до «Instrument»): новые термины | Glossary | См. раздел «Glossary / Navigation Impact» | `proposal.md`; `specs/.../portfolio-concentration/spec.md`; решение владельца (3083) | Формат: `## Термин`, определение и «_Не использовать_»; ссылки на домен |
|
||||
| Изменить | `docs/overview.md`, таблица «Основные задачи пользователя», строка «Понять текущий состав инвестиций» | Navigation | Добавить задачу «увидеть, где сконцентрированы деньги: бумаги, секторы, эмитенты» со ссылкой на портфель | `proposal.md` (Why) | markdownlint, relative-links |
|
||||
| Изменить | `docs/README.md`, таблица «Предметные области», строка «Портфель» | Navigation | Строка «Позиции, остатки, суммы, структура и свежесть» дополняется концентрацией (и ссылкой на новый файл, если он появится) | Как выше | markdownlint, relative-links |
|
||||
| Новый ADR | `docs/adr/022-stored-instrument-classification.md` и строка в `docs/adr/README.md` | ADR | Фиксирует исключение: сектор и бренд актива хранятся как последнее известное значение с отдельным возрастом, а не запрашиваются онлайн. Область: сектор для `STOCK`/`BOND`/`FUND`, бренд актива; 24-часовой отсчёт; сохранение при ошибке; глобальная таблица без привязки к подключению | `design.md` B1, D1–D2; PVΔ:104; ADR 019; решение владельца (3083) | Номер и отсутствие дубликата проверить на момент записи; ссылки проходят |
|
||||
| Изменить (связь) | `docs/adr/019-hourly-reads-and-online-instrument-reference.md`, строка **Status** | ADR | Добавить: «дополнен [ADR 022](022-stored-instrument-classification.md) в части сектора и бренда актива позиции». Текст решения 019 не переписывается | `docs/CONTRIBUTING.md` («Подтверждённо заменённое решение связывается с новым, а не переписывается») | Ссылка открывается; markdownlint |
|
||||
| Изменить (условно) | `docs/domains/portfolio/all-accounts.md`, раздел «Маршруты и навигация» | Navigation | Одна строка: вкладки «Концентрация» в общем режиме нет. Если владелец решит, что это не нужно описывать — не менять | `specs/.../portfolio-concentration/spec.md` («Режим «Все счета»») | Решение при написании |
|
||||
|
||||
### Что не меняется (и почему)
|
||||
|
||||
| Target | Причина |
|
||||
| --- | --- |
|
||||
| `docs/architecture/containers.md`, Mermaid-диаграмма Container и раздел «Контейнерная поставка» | Новых внешних систем и контейнеров нет; T-Bank Brands CDN используется только для изображений, как и раньше |
|
||||
| `docs/architecture/context.md`, Mermaid-диаграмма и текст | Тот же набор внешних систем |
|
||||
| `docs/architecture/http-api.md` | Контракт генерирует backend (OpenAPI). Новые необязательные поля не копируются в docs |
|
||||
| `docs/domains/accounts/overview.md` | Перечня вкладок страницы счёта в docs нет; вкладка относится к портфелю |
|
||||
| `docs/domains/instruments/stocks.md`, `docs/domains/instruments/bonds.md` | Сектор и бренд показываются только во вкладке счёта. Файлы **не читались целиком** — проверить поиском «сектор» перед PR |
|
||||
| `docs/domains/portfolio/all-accounts.md` (кроме условной строки выше) | Общий режим без вкладки; новых маршрутов нет |
|
||||
| `docs/CHANGELOG.md` | Генерируется release-it из коммитов; вручную не править |
|
||||
| `docs/architecture/cross-cutting/ui-resilience.md` | Не читалось полностью. Проверить, описывает ли оно состояния страницы счёта |
|
||||
|
||||
## Diagram Promotion
|
||||
|
||||
Перенос диаграмм не требуется. По умолчанию перенос не делается (`schema.yaml`, `docs-impact`; `docs/CONTRIBUTING.md`).
|
||||
|
||||
- `analysis/ux-design.md`, Mermaid flowchart «Страница счёта → Концентрация» — UI-поток этого change, не системная архитектура. В docs не переносится.
|
||||
- `visuals/*.svg` — материалы Visual Package; в docs не размещаются.
|
||||
- Существующие диаграммы (Container, Context, sequence подключения T-Bank) не меняются.
|
||||
- Sequence «часовой обход → классификация → Snapshot» в docs **не добавляется**: поток описан текстом в `portfolio-loading.md`. Если понадобится, её делает автор docs-PR по `design.md` B2.
|
||||
|
||||
## ADR Decision
|
||||
|
||||
**Практика ADR в docs есть:** журнал `docs/adr/`, индекс в `docs/adr/README.md`, последний номер — 021, следующий — **022**. Формат — по ADR 019: `# NNN. Название`, `**Status:**`, `## Decision`, `## Consequences`. Принятый ADR не переписывается; замена связывается ссылкой на новый (`docs/CONTRIBUTING.md`).
|
||||
|
||||
**Решение владельца (3083):** добавить ADR для хранения сектора и бренда как исключения из online-only справочных данных.
|
||||
|
||||
**ADR 022 — `docs/adr/022-stored-instrument-classification.md`, статус `Accepted`.** Содержание, подтверждённое источниками:
|
||||
|
||||
- Context: ADR 019 (справочные сведения онлайн; старые строки не источник актуальности); PVΔ:104 (исключение для сектора и бренда); design B1 (общий metadata-кэш не подходит: `Instrument` не содержит сектора, `brand` — `BrandData`, а не `Brand{uid,name}`).
|
||||
- Decision: глобальная таблица `TbankInstrumentClassification` с последним известным значением сектора и бренда, возраст полей раздельно; обновление по правилу PVΔ:141 (не раньше 24 ч после последнего успешного получения); четыре исхода PVΔ:156; без привязки к пользователю и подключению.
|
||||
- Consequences: первый долговременный справочник T-Bank в системе; число вызовов T-Bank растёт при первом обходе (разовый всплеск, ограничен D6); отказ token обрабатывается как в обходе Portfolio; пересмотр, если появится второй потребитель (design ADR Candidates).
|
||||
- Альтернативы: design Solution Assessment, варианты B и C. В ADR описываются кратко, со ссылкой на design.
|
||||
|
||||
ADR 022 пишется в том же docs-PR, что и изменения AS-IS, или раньше — и тогда в статус ADR 019 добавляется ссылка в том же PR, что и ADR 022.
|
||||
|
||||
## Glossary / Navigation Impact
|
||||
|
||||
### Глоссарий (`docs/glossary.md`)
|
||||
|
||||
Новые термины — в блоке Portfolio (после «Currency Instrument Position», до «Instrument»). Формат: `## Термин`, краткое определение, строка `_Не использовать_`.
|
||||
|
||||
| Термин | Что писать |
|
||||
| --- | --- |
|
||||
| **Концентрация** | Вкладка страницы одного счёта с разрезами по бумагам, секторам и эмитентам текущего Portfolio. Ссылка на домен портфеля. Не «структура» |
|
||||
| **Разрез** | Одна из четырёх группировок вкладки «Концентрация»: набор элементов, на которые делится оценка позиций |
|
||||
| **Сектор** | Значение сектора T-Bank для позиций `STOCK`, `BOND`, `FUND`, без собственной классификации WealthLedger. Государственные облигации — значение `government` с подписью «Государственный сектор» |
|
||||
| **Эмитент** | В разрезе «Эмитенты» вкладки «Концентрация» — бренд актива по данным T-Bank. Фонды в разрез не входят. **Оговорка (решение владельца 3083):** в этом разрезе слово означает бренд актива T-Bank, а не юридического эмитента ценной бумаги. Строка «_Не использовать_»: юридический эмитент, эмитент рейтинга (см. [рейтинг выпуска НКР](integrations/nkr.md)) |
|
||||
| **«Прочее»** | Группа остальных элементов разреза при семи и более элементах. Это UI-подпись: описывается в доменной странице, в глоссарии — одна строка-ссылка |
|
||||
|
||||
Владелец оставил термин «Эмитенты». Конфликт с юридическим эмитентом в NKR, ADR 013, `context.md` и `bonds.md` сохраняется; оговорка в глоссарии — единственное место, где он снимается. Остальные страницы, использующие «эмитент» в значении рейтинга или выпуска, не меняются.
|
||||
|
||||
Не добавлять отдельными записями: «Сектор не указан», «Эмитент не указан», «Государственный сектор» — UI-подписи, описываются в доменной странице.
|
||||
|
||||
Связанная существующая запись: «Instrument Logo» (строка про `logoName`) — после change явно указать, что логотип не равен эмитенту.
|
||||
|
||||
### Навигация
|
||||
|
||||
- `docs/domains/portfolio/overview.md` — главная страница для концентрации; ссылки на `data-reference.md` и новый файл (если будет).
|
||||
- `docs/overview.md`, `docs/README.md` — строки из таблицы изменений.
|
||||
- `docs/architecture/README.md` — новых вопросов не добавляется.
|
||||
- `docs/adr/README.md` — строка 022 в индексе.
|
||||
|
||||
## Порядок относительно реализации
|
||||
|
||||
Порядок из `docs/CONTRIBUTING.md` («Фазы работы»), `docs/AGENTS.md` и правила AS-IS. Design задаёт выкат: backup, миграция `CREATE TABLE`, backend, публикация OpenAPI, frontend (`design.md`, Migration / Rollout).
|
||||
|
||||
1. **Сейчас (G2 approved, G3 — design package):** AS-IS страницы не меняются. Создать пункты `[docs]` в `tasks.md` по таблице выше.
|
||||
2. **Во время реализации:** черновики текста допустимы в issue-linked ветке docs от `develop`. ADR 022 — в статусе `Accepted` (решение принято владельцем 3083).
|
||||
3. **Слияние в `develop` docs-PR** — после слияния backend и frontend и проверки на окружении: AS-IS-тексты не должны обещать поведение, которого в `develop` нет. ADR 022 можно слить раньше, если его текст не описывает поведение, а фиксирует решение.
|
||||
4. **Перед завершением:** сверка «текст ↔ код ↔ Specs»; `docs-sync-audit` или ручная проверка; проверки из следующего раздела.
|
||||
|
||||
Заголовок PR — conventional commit (`docs/commitlint.config.cjs`; CI `commits.yml` запускает `commitlint --strict`). В описании PR — ссылка на Issue #48 и change-id.
|
||||
|
||||
## AS-IS правила
|
||||
|
||||
- Docs описывает только поведение, которое есть в `develop` и проверено. Спецификация change не копируется в docs как требование.
|
||||
- Формулировки «будет», «планируется» в AS-IS-страницах не используются.
|
||||
- Текущие утверждения, которые станут неверными после change (исправлять только после реализации):
|
||||
- `docs/architecture/cross-cutting/data-freshness.md` — список данных SQLite и онлайн-правило для инструментов;
|
||||
- `docs/domains/portfolio/flows/portfolio-loading.md` — «Metadata инструмента дополняет представление независимо»;
|
||||
- `docs/adr/019-…` — онлайн-правило: исключение описывает ADR 022; текст 019 не переписывать;
|
||||
- `docs/domains/portfolio/data-reference.md` — «Структура» и «Концентрация» не должны выглядеть одним показателем.
|
||||
- Один факт — один источник: правила сектора и бренда живут в `portfolio-view` Specs; docs ссылается на них, а таблицы исходов из `failure-model.md` не копирует.
|
||||
- Имена RPC T-Bank в docs упоминаются только как категории источников; точный контракт — код и `design.md`.
|
||||
|
||||
## Проверки
|
||||
|
||||
Из корня docs (`docs/`), по `docs/CONTRIBUTING.md` и `docs/AGENTS.md`:
|
||||
|
||||
```bash
|
||||
nvm use --silent # если nvm не загружен, сначала загрузить $HOME/.nvm/nvm.sh (см. корневой AGENTS)
|
||||
npm ci # если зависимости не установлены
|
||||
npm run verify # = npm run lint && git diff --check
|
||||
```
|
||||
|
||||
- `npm run lint`: markdownlint по `**/*.md` (кроме `node_modules` и `CHANGELOG.md`) с правилом относительных ссылок; отдельно `CHANGELOG.md`.
|
||||
- Коммит: `npm run commitlint -- --strict`.
|
||||
- Ручные проверки, которые lint не делает: якоря в ссылках; смысловая сверка с кодом и Specs; маршрут чтения в `README.md`; новый файл достижим из индекса.
|
||||
- **Рендер Mermaid не проверяется** ни одним инструментом из `package.json`. Если меняется диаграмма, отмечать «не проверено рендером».
|
||||
- Для ADR 022: номер, отсутствие дубликата в `docs/adr/README.md`, ссылка из ADR 019.
|
||||
|
||||
В этой задаче проверки не запускались: правок в docs нет.
|
||||
|
||||
## Открытые вопросы и расхождения
|
||||
|
||||
1. **Конфликт термина «эмитент»** сохраняется вне вкладки: NKR, ADR 013, `context.md`, `bonds.md` используют слово для юридического эмитента. Оговорка в глоссарии снимает конфликт только для вкладки; остальные страницы — решение владельца, если оно понадобится.
|
||||
2. **Конфликт с ADR 019** снимается ADR 022 (решение 3083). Текст ADR 019 остаётся историческим.
|
||||
3. **Не полностью прочитаны:** `docs/domains/instruments/stocks.md`, `bonds.md`, `docs/architecture/cross-cutting/ui-resilience.md`, `docs/integrations/README.md`. Проверить поиском перед PR.
|
||||
4. **Основание решения 3083** в этом файле не цитируется дословно: текст комментария Issue не читался в этой задаче. Перед написанием ADR 022 сверить формулировку с комментарием.
|
||||
5. **Вне scope:** `docs/code-quality-review.md` лежит в корне docs, хотя `docs/AGENTS.md` запрещает аудиты в docs. Не трогается.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Модель отказов
|
||||
|
||||
## Контекст и границы
|
||||
|
||||
Модель описывает получение сектора и бренда актива в часовом обходе, а не новый процесс синхронизации. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](system-analysis.md). Источники и преобразования — [data-mapping.md](data-mapping.md). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
|
||||
|
||||
Нормативная основа. Поведение при получении сектора и бренда определяют Specs; модель их не пересказывает и не дополняет, а относит отказы T-Bank к соответствующим исходам:
|
||||
|
||||
- PVΔ:141 «Обновление сектора и эмитента» — когда получение происходит.
|
||||
- PVΔ:156 «Результат получения сектора и эмитента» — четыре исхода; для каждого таблица задаёт поле позиции и отсчёт 24 часов. Неуспешное получение не мешает обновлению остальных данных Portfolio.
|
||||
- PVΔ:104 «Свежесть и fallback полного Portfolio Snapshot» — хранимое последнее известное значение и исключение «отказ token».
|
||||
- PVΔ:3 «Нормализованный состав Portfolio» — сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность и свежесть Snapshot, кроме отказа token.
|
||||
|
||||
Финансовый Snapshot по-прежнему требует успешных GetPortfolio и GetPositions (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`; `backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-283`). Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank/tbank-data-sync/spec.md:9-14`). Новых очередей, блокировок, таблиц и библиотек модель не вводит; их выбор — design.
|
||||
|
||||
## Классификация отказов по исходам PVΔ:156
|
||||
|
||||
Для сопоставления отказов с нормой используется только эта таблица; значения полей и отсчёт в неё не копируются.
|
||||
|
||||
| Наблюдаемое событие | Исход по PVΔ:156 |
|
||||
| --- | --- |
|
||||
| Получено непустое значение | «Получено значение» |
|
||||
| Ответ «не найдено» (в том числе NOT_FOUND на уровне RPC), нет поля / пусто / только пробелы после обрезки, нет `asset_uid`, актива или бренда, непригодная пара `brand.uid` / `brand.name` | «Ответ „не найдено“ или значения нет» |
|
||||
| Ответ с UID / FIGI / `asset_uid` не запрошенного инструмента или актива | «Ответ о другом инструменте или активе» |
|
||||
| Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, переполнение локальной очереди, ошибка декодирования, любой иной код, кроме отказа token | «Иная ошибка, кроме отказа token» |
|
||||
| PERMISSION_DENIED, UNAUTHENTICATED (сведены клиентом к InvalidToken) | Отказ token: не из таблицы, обработка по PVΔ:104 |
|
||||
|
||||
Следствие: таблица PVΔ:156 содержит ровно четыре исхода плюс отказ token; других вариантов поведения модель не вводит.
|
||||
|
||||
## Сценарии отказов
|
||||
|
||||
«Известное значение» — значение, которое сохранено в позиции по PVΔ:104. Сектор и бренд получаются независимо.
|
||||
|
||||
| ID | Граница / шаг | Отказ | Обнаружение | Требуемое поведение системы | Наблюдаемый результат | Requirement |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| F01 | T-Bank → sync: финансовые ответы | GetPortfolio или GetPositions не получен | Исход RPC | Новый полный Snapshot не публикуется; действует прежний fallback Portfolio. Успех получения sector/brand его не заменяет | Прежний Snapshot с `isStale` либо отсутствие данных | `portfolio-view:96` (canonical); `hourly-investment-sync:8` |
|
||||
| F02 | Получение сектора или бренда | Timeout, UNAVAILABLE, RESOURCE_EXHAUSTED, ошибка декодирования — иная ошибка, кроме отказа token | Код RPC после попыток клиента | По строке «Иная ошибка, кроме отказа token» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156 |
|
||||
| F03 | Получение бренда (инструмент → `GetAssetBy`) | То же для любого из двух запросов цепочки | Исход RPC | Как F02 — по той же строке PVΔ:156; сектор и бренд независимы | Как F02 | PVΔ:156 |
|
||||
| F04 | Успешный ответ без значения, «не найдено» | Нет `sector`, `asset_uid`, `brand`, пусто или только пробелы; ответ «не найдено» | Валидация маппинга (признаки — data-mapping); код NOT_FOUND | По строке «Ответ „не найдено“ или значения нет» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (обрезка пробелов) |
|
||||
| F05 | Идентификация | Ответ о другом инструменте или активе | Сравнение UID / FIGI / `asset_uid` с запросом | По строке «Ответ о другом инструменте или активе» PVΔ:156 | Результат определён строкой PVΔ:156 | PVΔ:156; PVΔ:3 (запрет определять эмитента по названию) |
|
||||
| F06 | Обогащение набора позиций | Часть позиций получена, часть нет | Результат по каждому полю | Каждое поле — по своему исходу PVΔ:156; Snapshot публикуется целиком | Известные и неизвестные группы наравне; суммы не меняются | PVΔ:156; PC:349 |
|
||||
| F07 | Лимитер / квота T-Bank | RESOURCE_EXHAUSTED либо переполнение локальной очереди | Код RPC; локальный отказ admission | Ограниченные повторы клиента (см. «Таймауты и повторы»); после них — F02 | Как F02 | PVΔ:156 |
|
||||
| F08 | Отказ token при получении | PERMISSION_DENIED или UNAUTHENTICATED из запроса сектора или бренда | Код gRPC → InvalidToken | Обрабатывается так же, как отказ token при обходе Portfolio | Как задано PVΔ:104 (сценарий «Отказ token при получении бренда»); повторов в этом обходе нет | PVΔ:104 |
|
||||
| F09 | Публикация финансового Snapshot | Транзакция не завершена | Отказ публикации | Нового успеха нет, остаётся прежний Snapshot; обогащение не маскирует отказ | Нет ложного `lastSuccessfulAt` | `portfolio-view:96` (canonical) |
|
||||
| F10 | Сохранение полученных sector/brand | Данные получены, но не сохранены | Исход записи; несовпадение revision | Финансовый успех не отменяется. Значение не считается полученным: исход, который его сохранил бы, не наступил, поэтому поле и отсчёт 24 часов остаются прежними, а следующий обход повторяет получение, пока значение не получено (PVΔ:141) | Прежнее значение либо `null` | PVΔ:141; PVΔ:104 |
|
||||
| F11 | Границы подключения | Token заменён, подключение удалено, счёт недоступен | Revision, membership, attempt | Поздний результат прежнего подключения не публикуется в новое; хранимые sector/brand относятся к подключению и не переходят в новое | Прежние `404`/`409`/`400`; нет утечки данных | `portfolio-view:99` (canonical); PVΔ:104; `docs/domains/portfolio/flows/portfolio-loading.md:24-29` |
|
||||
| F12 | GET → frontend | Временная ошибка HTTP-чтения | Состояние Portfolio query | Сохранённые данные и разрезы остаются; HTTP-повтор читает только локальные данные. Вкладка не показывается при загрузке, ошибке и недоступности | Как на «Позициях» | PC:20; PC:484 |
|
||||
| F13 | Старый JSON / Client Cached Snapshot | Ключей `sector`/`issuer` нет | Нет ключей | Трактуются как неизвестные; в ответе `null` | Группы «не указан» до ближайшего получения | PVΔ:3; PC:349 |
|
||||
| F14 | Свежесть и полнота Portfolio | Snapshot устарел или `meta` обозначает неполноту, а sector/brand получены | `isStale`, `meta` | Время sector/brand не освежает финансовый Snapshot; при неполноте — единственное сообщение «Данные неполные» на вкладке, страничный Alert на этой вкладке не показывается | Разрезы по доступным данным; одно сообщение о неполноте | PC:464; PC:484 |
|
||||
| F15 | Данные разреза | Нет учтённых позиций | Клиентская фильтрация | Пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | Нет NaN и фиктивных 100 % | PC:451; PC:129 |
|
||||
| F16 | Долгая недоступность | Получение не удаётся дольше 24 часов | Время последнего успешного получения | По строке «Иная ошибка, кроме отказа token» PVΔ:156 каждый обход; предельного возраста у хранимого значения нет | Значение может отставать дольше 24 ч + интервал обхода, пока T-Bank не ответит | PVΔ:156; PVΔ:141; PVΔ:104 |
|
||||
|
||||
Группа «не указан» не различает причину: отсутствие у T-Bank, технический сбой, старый Snapshot. Новой пользовательской ошибки или статуса качества не вводится. Диагностика — существующие логи синхронизации (`specs/openspec/changes/account-top-positions/process-record.md:15-17`).
|
||||
|
||||
## Таймауты и повторы
|
||||
|
||||
### Подтверждённые ограничения AS-IS
|
||||
|
||||
| Механизм | Факт | Источник |
|
||||
| --- | --- | --- |
|
||||
| Deadline одного RPC | 15 000 мс | `backend/src/integrations/tbank/tbank.client.ts:55,589-613` |
|
||||
| Попытки одного вызова | Максимум 2; пауза 1 000 мс | `backend/src/integrations/tbank/tbank.client.ts:51-53,518-562` |
|
||||
| Повторяемые коды | DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, UNAVAILABLE | `backend/src/integrations/tbank/tbank.client.ts:548-552` |
|
||||
| Прочее | PERMISSION_DENIED / UNAUTHENTICATED → InvalidToken; NOT_FOUND → `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальное → Unavailable | `backend/src/integrations/tbank/tbank.client.ts:571-583` |
|
||||
| Очередь и скорость | Общий лимитер клиента, очередь 50, по умолчанию 5 стартов/с (локальная настройка, не квота T-Bank) | `backend/src/integrations/tbank/tbank.client.ts:50-52,147-187,259-261`; `backend/src/config/env.validation.ts:5,29-31` |
|
||||
| Ожидание шага обхода | 900 000 мс; истечение не отменяет операцию | `backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:28-33` |
|
||||
| Часовой обход | Не ранее 60 минут от старта прошлой попытки; без очереди пропущенных и без перекрытий | `specs/openspec/specs/tbank/hourly-investment-sync/spec.md:8-21` |
|
||||
|
||||
Две сетевые попытки по 15 с с паузой дают до 31 с без ожидания лимитера. Это не общий timeout цепочки «инструмент → актив». 15 минут планировщика не являются обещанием срока получения sector/brand.
|
||||
|
||||
### Требования, вытекающие из Specs
|
||||
|
||||
- Момент получения и повторы между обходами определяет PVΔ:141: получение при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе. Внутри обхода повторы — только прежние до двух попыток клиента. Новые циклы по открытию экрана, HTTP-повтору или раскрытию «Прочее» запрещены (`tbank-data-sync:9-14`).
|
||||
- Обходы, начавшиеся раньше срока, значение не меняют (PVΔ:141, сценарий «Обход раньше 24 часов»); значения, у которых срок истёк одновременно, получаются в одном обходе.
|
||||
- **Требование к реализации, не выбор.** NOT_FOUND от `EtfBy`, `BondBy` и `GetAssetBy` относится к исходу «значения нет» (PVΔ:156, решение 3082 «Как „значения нет“»). Текущий клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальное — в Unavailable (`tbank.client.ts:577-583`); без изменения такой ответ был бы ошибочно отнесён к «иной ошибке». Реальный вид ответа «не найдено» у каждого метода проверяется как Q-F3.
|
||||
- Числовой общий бюджет времени получения и новый SLA Specs не задают; публикация финансов не должна ждать получения (PVΔ:3). Распределение вызовов внутри обхода — design.
|
||||
|
||||
### Число дополнительных вызовов
|
||||
|
||||
Обозначения: **P** — число позиций STOCK/BOND/FUND; **U** — число различных инструментов среди них; **A** — число различных `asset_uid`. У остальных типов вызовов нет.
|
||||
|
||||
- Без повторного использования результатов точечная схема даёт до `2P` логических вызовов: типизированный инструмент (sector и `asset_uid`) и `GetAssetBy` (бренд).
|
||||
- С повторным использованием: до `U + A` в обход, когда срок истёк; при двух попытках клиента — до `2 × (U + A)` сетевых попыток. Если нужен ещё `GetInstrumentBy` для идентификации либо FIGI-fallback, число растёт. Оценка не выбирает алгоритм и не вводит квоту.
|
||||
- При тёплых значениях — близко к нулю между суточными сроками; при систематических ошибках — до `U + A` каждый обход.
|
||||
- `GetAssets` заменить `GetAssetBy` не может: в `Asset` нет `brand` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:1075-1080`).
|
||||
- 5 стартов/с делится с остальными вызовами клиента; соответствие внешним квотам не доказано (Q-F3).
|
||||
|
||||
## Идемпотентность и обработка дубликатов
|
||||
|
||||
Все внешние вызовы — чтения: повтор не создаёт активов и позиций и не меняет стоимость. Повтор получения заменяет значение поля по PVΔ:156, но не добавляет вторую позицию.
|
||||
|
||||
Публикация финансов защищена в AS-IS: scope — пользователь + revision подключения + счёт; работа ограждена `id`, `attemptId` и состоянием `running`; поздняя публикация завершённой попытки не проходит (`backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:13-18,116-150`). HTTP-ключ идемпотентности для GET не нужен.
|
||||
|
||||
Для получения sector/brand ключ соответствия — провайдер T-Bank + instrument UID (затем FIGI); для бренда — `asset_uid`; для группировки на клиенте — `issuer.id`. Текущий `metadataKey` и объединение запросов по ключу (`backend/src/integrations/tbank/instrument-metadata.repository.ts:33-38`; `backend/src/integrations/tbank/instrument-metadata.service.ts:69-98`) подтверждают AS-IS, но не задают новый кэш. Обязательство design: обеспечить отсчёт срока по `PVΔ` «Обновление сектора и эмитента»; гранулярность и способ хранения времени последнего успешного получения выбирает design. Дедупликация одновременных запросов допустима, но не должна запрещать повтор, который требует PVΔ:141.
|
||||
|
||||
## Порядок и конкуренция
|
||||
|
||||
- Значение присваивается только по исходам PVΔ:156; ответ актива не применяется к произвольной позиции по названию. Сектор и бренд независимы и могут завершиться в любом порядке.
|
||||
- Полнота и время финансового Snapshot определяются финансовыми ответами; одновременный GET может увидеть ещё не обновлённое значение, и это допустимо.
|
||||
- Поздний результат прежнего подключения или пользователя не принимается; существующие ограждения публикации сохраняются (`portfolio.repository.ts:121-147`).
|
||||
- `issuer` — пара целиком; `id` из одного ответа не сочетается с `name` из другого. Если разные позиции одного бренда несут разные `name`, выбор имени — клиентский (PC:114).
|
||||
- Один инструмент на нескольких счетах использует одно определение; позиции счетов не объединяются.
|
||||
- Часовые обходы не перекрываются; истёкшее ожидание не отменяет вызов, поздний ответ проверяет применимость (`hourly-investment-sync:8`).
|
||||
|
||||
## Частичный отказ и компенсация
|
||||
|
||||
При полном финансовом успехе и неполном получении публикуется весь состав с доступными значениями. Известный `sector` при неизвестном `issuer` допустим и наоборот; неудача одной позиции не затрагивает остальные. Отсутствие значения не удаляет позицию, не меняет `currentValue` и не переводит Portfolio в partial (PVΔ:3).
|
||||
|
||||
Компенсаций нет: чтение справочника внешних эффектов не создаёт. Откатывать успешные деньги из-за бренда нельзя. Если финансовая публикация не завершилась — это F09, а не отсутствие metadata.
|
||||
|
||||
Исключение — отказ token (F08): его последствия задаёт PVΔ:104; подключение становится недействительным так же, как при отказе token в обходе Portfolio (реакция AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:194-196`).
|
||||
|
||||
## Восстановление
|
||||
|
||||
1. Следующий обход, начавшийся не ранее срока PVΔ:141, либо любой следующий обход, пока значение не получено, повторяет получение; ручная синхронизация не нужна.
|
||||
2. Результат каждого получения определяет PVΔ:156.
|
||||
3. Хранимое значение живёт без предельного возраста (F16, PVΔ:104).
|
||||
4. После замены подключения хранимые значения прежнего подключения не используются; восстановление идёт в новом подключении (F11).
|
||||
5. GET между обходами читает хранимое и не вызывает T-Bank (PVΔ:104, сценарий «Сектор и бренд читаются из хранимого значения»).
|
||||
6. **Расхождение AS-IS с нормами, которое закрывает реализация.** Общая metadata считается свежей по факту завершённого lookup без возраста, а запрос с `force=false` пропускает такие записи (`instrument-metadata.repository.ts:314-323`; `instrument-metadata.service.ts:75-79`). Для PVΔ:141 и PVΔ:156 это недопустимо: срок считается от времени последнего успешного получения каждого поля, а не от факта завершённого lookup.
|
||||
|
||||
## Совместимость во время отказа
|
||||
|
||||
- Старые потребители игнорируют новые поля; Money и состояния ресурса не меняются.
|
||||
- Старый JSON не пересоздаётся; отсутствие ключей на HTTP-границе — `null`.
|
||||
- Если metadata нет совсем, положительные позиции образуют группы «не указан», а не пустое состояние. Пустое состояние — только при отсутствии учтённых позиций (PC:451).
|
||||
- Client cache не обходит `404`, отсутствие подключения и известный недействительный token (`specs/openspec/specs/portfolio/portfolio-view/spec.md:150-157`).
|
||||
- Ошибки sector/brand не попадают в `meta` Portfolio как причина неуспеха или устаревания. Отдельный статус качества metadata потребовал бы Specs.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
|
||||
|
||||
- Срок и поведение после ошибки — PVΔ:141 и PVΔ:156.
|
||||
- Отказ token — PVΔ:104.
|
||||
- «Не найдено» от T-Bank — исход «значения нет» (PVΔ:156).
|
||||
- Хранение вопреки «справочные сведения онлайн» — исключение для сектора и бренда (PVΔ:104).
|
||||
- Два предупреждения о неполноте — только на вкладке (PC:464).
|
||||
|
||||
Остаётся неизвестным. Это факт о внешней системе и нагрузке; он проверяется при реализации и решения владельца не требует.
|
||||
|
||||
| ID | Что неизвестно | Чем проверяется |
|
||||
| --- | --- | --- |
|
||||
| Q-F3 | Фактические квоты `ShareBy`, `BondBy`, `EtfBy`, `GetAssetBy` для токена владельца; число U и A; доля обращений при холодном старте; в каком виде T-Bank отдаёт «не найдено» для `EtfBy`, `BondBy`, `GetAssetBy` (код NOT_FOUND либо пустой объект) | Официальные лимиты Invest API; измерение числа вызовов и времени в первом полном обходе по событиям `tbank.rpc.*` (`tbank.client.ts:536-569`); запрос с несуществующим `asset_uid` на тестовом окружении или мок по proto. Результат определяет параллелизм и признак ответа «не найдено» в клиенте; сама норма исхода от него не зависит |
|
||||
@@ -0,0 +1,175 @@
|
||||
# QA Analysis
|
||||
|
||||
Ревизия спеков: G2 approved (comment 3082). Источник ожидаемого поведения — только
|
||||
Delta Specs `portfolio-concentration` (28 ADDED, ниже PC) и `portfolio-view`
|
||||
(2 MODIFIED + 2 ADDED, ниже PVΔ) этого change. Design пишется параллельно; на его
|
||||
внутреннее устройство не опираемся. Case пишется только там, где нужны данные, шаги или oracle сверх Scenario;
|
||||
остальные строки Coverage Matrix ссылаются на Scenarios. Написанные cases — в
|
||||
`analysis/test-cases/{conc,meta,reg}.md`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Риск | Затронутые требования | Вероятность | Ущерб | Глубина проверки |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Неверная классификация исхода получения (NOT_FOUND vs иная ошибка) ломает 24h-отсчёт и last-known | «Результат получения сектора и эмитента», F02–F05 | Высокая (клиент AS-IS отображает NOT_FOUND как Unavailable — failure-model:75) | Средний: группы «не указан» вместо значений | Интеграционные проверки всех 4 строк таблицы исходов + exploratory Q-F3 |
|
||||
| Доли/суммы считаются от неверной базы (Cash Balance, нули, другие валюты, отрицательные) | PC:129, PC:144, PC:289 | Средняя | Высокий: недоверие владельца к цифрам | Компонентные проверки с числовыми оракулами из сценариев |
|
||||
| Потеря/порча финансового Snapshot из-за обогащения (token, публикация, границы подключения) | «Свежесть и fallback полного Portfolio Snapshot», F08–F11 | Низкая | Критический: потеря данных портфеля | Интеграционные: token-отказ, гонка публикации, смена подключения; регрессия обхода |
|
||||
| Ошибка округления/формата долей (`,0`, `< 0,1 %`, половина вверх) | PC:304, PC:324 | Средняя | Низкий | Компонентные с точными строками-эталонами |
|
||||
| Ухудшение существующей вкладки «Позиций» / структуры портфеля | PC:9, регрессия | Средняя | Высокий | Регрессионный прогон (TC-REG) |
|
||||
| Доступность диаграммы только цветом / недоступна скринридеру | PC:443 | Средняя | Средний | Ручная проверка + текстовый эквивалент в component |
|
||||
| Квоты T-Bank при суточном обходе (до `U + A` вызовов) | F07, Q-F3 | Неизвестная | Средний: деградация синхронизации | Exploratory Q-F3 на тестовом токене; norm от квот не зависит |
|
||||
|
||||
## Coverage Matrix
|
||||
|
||||
Уровни: unit / integration (backend, configured HTTP tests) / component (frontend) / e2e / manual.
|
||||
Ссылки на сценарии — `PC:<строка>` / `PVΔ:<строка>` файлов спек.
|
||||
|
||||
| Requirement | Сценарии / ветви | Проверка / ожидаемое наблюдение | Уровень | Test cases |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PC:9 Вкладка «Концентрация» страницы счёта | 2 сценария (есть вкладка; «Все счета» — нет) + инвариант «прочие вкладки не меняются» | Вкладка рядом с «Позициями»; в «Всех счетах» отсутствует; дефолтная вкладка прежняя | e2e | Scenario «Вкладка на странице одного счёта», «Режим “Все счета”»; TC-REG-001 |
|
||||
| PC:20 Вкладка в состояниях страницы | загрузка/ошибка/пусто/только Cash Balance | Вкладки нет при загрузке, ошибке и пустом Portfolio; есть при любом Cash Balance | component + e2e | TC-CONC-002 + Scenario «Загрузка и ошибка» |
|
||||
| PC:37 Переключение разрезов | 4 разреза, один одновременно, порядок | Показан ровно один разрез, переключатели в заданном порядке | component | Scenario «Открытие вкладки», «Переключение разреза» |
|
||||
| PC:49 Начальное состояние вкладки | новый заход/reload → «Акции»+первая валюта; вкладки страницы → сохранение | Reset при новом заходе и смене счёта, persist при переключении вкладок | e2e | TC-CONC-005, TC-CONC-006 |
|
||||
| PC:69 Состав разрезов | 3 сценария (сектор объединяет; эмитент stock+bond без фонда; фонд вне акций/облигаций) | Агрегация и исключение типов по таблице | component | TC-CONC-007 |
|
||||
| PC:96 Названия элементов | без name → ticker; `issuer.name`; разные `issuer.id` не сливаются | Название по цепочке name→ticker→FIGI; раздельные элементы при равных именах | component | TC-CONC-008 |
|
||||
| PC:114 Название эмитента при разных названиях бренда | наибольшее `currentValue`; равные → меньший по алфавиту | Элемент назван по большей позиции; при равенстве — «Сбер» < «Сбербанк» | component | Scenario «Название по наибольшей позиции», «Равные наибольшие значения» |
|
||||
| PC:129 Позиции, учитываемые в разрезе | положительные; 0 — не учитывается и не неучтённая; null/отрицательные исключены | Сумма разреза только из положительных позиций выбранной валюты | component | Scenario «Отрицательная и неоценённая позиции», «Позиция с нулевой стоимостью» |
|
||||
| PC:144 Сноска о не учтённых позициях | N: null в любой валюте + отрицательные в выбранной; 0 → нет сноски | Точный N по правилу; сценарии 149–167 покрывают все ветви | component | Scenario «Сноска с числом позиций», «Неоценённая позиция при смене валюты», «Отрицательная позиция другой валюты», «Нет неучтённых позиций» |
|
||||
| PC:169 Одна валюта за раз | смена валюты пересчитывает; сохраняется между разрезами; нет конвертации | Значения только в выбранной валюте, без смешанного итога | component | Scenario «Смена валюты», «Валюта сохраняется при смене разреза» |
|
||||
| PC:184 Доступные валюты вкладки | STOCK/BOND/FUND ненулевые, включая отрицательные; 0 исключён; Select скрыт при ≤1; сортировка ISO | Список и сортировка точны; скрытие Select; пустое состояние без выбора | component | Scenario «Начальная валюта», «Единственная валюта», «Валюта только у позиции с нулевой стоимостью», «Валюта только у отрицательной позиции», «Нет оценённых позиций» |
|
||||
| PC:214 Независимый выбор валюты вкладки | независимость от «Структуры портфеля»; пропавшая валюта → первая списка | Валюта вкладки не меняется от внешнего Select; fallback при исчезновении | component + e2e | TC-CONC-015 |
|
||||
| PC:229 Топ-5 и строка «Прочее» | ≤6 строк без «Прочее» (5, 6, <5); ≥7 → топ-5 + «Прочее» с числом элементов | Границы 5/6/7 и формулировка числа элементов («2 элемента», «5 элементов») | component | TC-CONC-016 |
|
||||
| PC:254 Раскрытие строки «Прочее» | раскрытие/сворачивание (мышь и клавиатура); не меняет топ-5 и диаграмму; свёрнуто при показе/смене валюты | Список остальных в порядке ранжирования; инвариант неизменности топ-5 | component + e2e | Scenario «Раскрытие “Прочее”», «Повторное нажатие», «Смена валюты сворачивает “Прочее”»; клавиатура — TC-CONC-027 |
|
||||
| PC:274 Порядок элементов с равной суммой | цифры < латиница < кириллица, без регистра; граница 5-й строки | Порядок «VK», «X5», «Сбер»; «Альфа» в топ-5 при равенстве | component | Scenario «Равные суммы на границе пятой строки», «Цифры, латиница и кириллица при равных суммах» |
|
||||
| PC:289 Расчёт долей | точные доли суммируются в 100 %; доля «Прочее» = сумма долей элементов; Cash Balance вне | Числовые оракулы сценариев 294–302 | component | Scenario «Доля без денежных остатков», «Доля “Прочее”»; числовой прогон — TC-CONC-007 |
|
||||
| PC:304 Показ долей | округление до десятых, половина вверх, без «,0», «< 0,1 %», сумма ≠ 100 % допустима | Строки-эталоны «93,8 %», «6,3 %», «< 0,1 %», 100,1 % в сумме | component | TC-CONC-020 (все три ветви: середина вверх, «< 0,1 %», без «,0», сумма ≠ 100 %) |
|
||||
| PC:324 Суммарная доля топ-5 | показ при ≥6, скрыта при ≤5 (7/6/5/1 элементы) | «Топ-5 · N %» только при ≥6; значения 95 %, 96 % | component | Ветви покрыты TC-CONC-016 (наборы 1–4) |
|
||||
| PC:349 Группы «Сектор/Эмитент не указан» | группа ранжируется наравне, попадает в «Прочее», имя видно | Сценарии 354–362 | component | Scenario «Позиции без эмитента в топе», «Группа без сектора в “Прочее”» |
|
||||
| PC:364 Подписи секторов | известные — русская подпись, неизвестные — как получено; нет своей классификации | Неизвестный код показан буквально | component | Scenario «Неизвестное значение сектора» |
|
||||
| PC:374 Русские подписи известных секторов | 11 значений таблицы, включая `government` | Каждое значение → своя подпись (таблица — эталон) | component | TC-CONC-024 |
|
||||
| PC:402 Диаграмма распределения разреза | 6 сегментов при ≤6; 5+«Прочее» при ≥7; одинаково при раскрытии; нет при пустом разрезе | Число сегментов и доли совпадают со списком | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
|
||||
| PC:415 Центр диаграммы | сумма точная; «Топ-5 · N %» при метрике; текстовый эквивалент | Центр: «95 %» + «1 000 ₽» / только сумма при ≤5 | component + manual | Scenario «Семь элементов», «Ровно шесть элементов» (обе диаграммные нормы); визуальный блок — TC-CONC-027 |
|
||||
| PC:430 Строки без переходов | клик по строке — нет навигации; эмитенты без логотипов | URL не меняется; нет `<img>` логотипов | e2e | Scenario «Нажатие на строку акции», «Строка эмитента» |
|
||||
| PC:443 Доступность диаграммы без цвета | текстовый эквивалент, сопоставимость сегментов без цвета | Скринридер читает название/сумму/долю каждого сегмента | manual + component | TC-CONC-027 |
|
||||
| PC:451 Пустое состояние разреза | пустой разрез: заголовок + валюта, без списка/диаграммы; сноска и выбор сохраняются | Сценарий «Валюта только у отрицательной позиции» | component | Scenario «Валюта только у отрицательной позиции» (пустое состояние) |
|
||||
| PC:464 Предупреждение о неполных данных | неполнота `meta` → одно «Данные неполные» на вкладке, без дублирования на странице; неоценённые позиции не вызывают | Одно сообщение; доли по доступным данным | component + e2e | Scenario «Неполный Portfolio», «Предупреждение не дублируется» |
|
||||
| PC:484 Состояния Portfolio на вкладке | соответствие «Состояниям и ошибкам Portfolio на клиенте» | isStale/загрузка/ошибка отображаются как на «Позициях» | component | Scenario «Состояния Portfolio на вкладке “Концентрация”»; сверка с canonical — существующий regression-прогон frontend |
|
||||
| «Нормализованный состав Portfolio» (MODIFIED) | sector/issuer nullable; обрезка пробелов; только пробелы = отсутствие; sector только у STOCK/BOND/FUND; issuer.id+name; запрет эмитента по названию | Поля по правилам; сценарии 30–60 delta; GET-контракт не сломан | integration (configured HTTP tests) | PVΔ Scenario «Сектор инструмента доступен/отсутствует», «Эмитент позиции доступен/отсутствует», «Эмитент не определяется по названию», «Недоступность сектора и эмитента не нарушает Snapshot»; обратная совместимость GET — существующий contract-прогон |
|
||||
| «Свежесть и fallback полного Portfolio Snapshot» (MODIFIED) | исключение: token-отказ при sector/brand = 400 INVALID_TBANK_TOKEN; сектор/бренд — last-known, обновление по «Обновление сектора и эмитента»; GET не ждёт T-Bank | Fallback не маскирует token-отказ; хранимые sector/brand читаются локально | integration | TC-META-002; существующий backend contract-прогон |
|
||||
| «Обновление сектора и эмитента» (ADDED) | первый обход ≥24 ч после успеха; интервал из hourly-investment-sync; «не найдено» тоже успех | Сценарии 149–152: значение обновляется в первый обход после 24 ч | integration | TC-META-003 |
|
||||
| «Результат получения сектора и эмитента» (ADDED) | 4 строки таблицы: значение / нет значения / другой инструмент / иная ошибка; каждая — поле + отсчёт 24 ч | Каждая строка: точное поле и отсчёт (сценарии 170–202, включая перезапуск отсчёта от «не найдено») | integration | TC-META-004 |
|
||||
|
||||
## Negative and Boundary Focus
|
||||
|
||||
- Границы элементов: 1 / 5 / 6 / 7 (строки и метрика топ-5) — TC-CONC-016.
|
||||
- Границы стоимости: 0 (исключена и не в N), отрицательная (в N только своей валюты),
|
||||
`null` (в N в любой валюте), крайне малая доля «< 0,1 %» — Scenario «Отрицательная и неоценённая»/«Нулевая стоимость»/«Сноска о не учтённых позициях», TC-CONC-020.
|
||||
- Округление: точная половина вверх (6,25 → «6,3»), сумма показанных ≠ 100 % — TC-CONC-020.
|
||||
- Валютные границы: единственная валюта (Select скрыт), валюта только отрицательной
|
||||
позиции (есть в выборе, разрез пуст), валюта пропала после обновления — Scenario «Доступные валюты вкладки», TC-CONC-015.
|
||||
- Тайбрейки: равные суммы (цифры/латиница/кириллица), граница 5-й строки, равные
|
||||
`currentValue` у одного `issuer.id`, одинаковые `issuer.name` при разных `id` — Scenario «Порядок элементов с равной суммой»/«Название эмитента при разных названиях», TC-CONC-008.
|
||||
- 24h-отсчёт: успех → сброс; «не найдено» → сброс; иной инструмент / иная ошибка →
|
||||
без сброса, повтор каждый обход; систематический сбой дольше 24 ч — TC-META-003/004.
|
||||
- Запрещённые эффекты: GET не запускает T-Bank, sector/brand не освежают Snapshot,
|
||||
enrichment не отменяет финансовый успех, «Прочее»-раскрытие не меняет топ-5 и диаграмму,
|
||||
строка не ведёт на другую страницу — TC-META-002, Scenario «Раскрытие “Прочее”», «Нажатие на строку акции».
|
||||
|
||||
## Cross-system Verification
|
||||
|
||||
T-Bank → sync → хранилище → GET → frontend. Каждый F-сценарий failure-model:
|
||||
|
||||
| ID | Проверка | Test cases |
|
||||
| --- | --- | --- |
|
||||
| F01 | Финансовый отказ не замещается успехом sector/brand; прежний Snapshot | TC-META-002, TC-REG-004 |
|
||||
| F02 | Иная ошибка: прежнее значение/`null`, отсчёт не меняется | TC-META-004 |
|
||||
| F03 | Сбой любого звена цепочки инструмента → бренда — как F02; поля независимы | TC-META-004 |
|
||||
| F04 | «Не найдено»/пусто/пробелы → `null` + сброс отсчёта | TC-META-004 + PVΔ Scenario «Ответ без значения заменяет известное значение» |
|
||||
| F05 | Чужой UID/asset_uid → прежнее значение, повтор | TC-META-004 |
|
||||
| F06 | Частичное обогащение: Snapshot публикуется целиком, группы наравне | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
|
||||
| F07 | RESOURCE_EXHAUSTED → как F02 после повторов клиента; (квоты — Q-F3) | TC-META-004 |
|
||||
| F08 | Token-отказ при sector/brand → 400 INVALID_TBANK_TOKEN, без fallback | TC-META-002, TC-REG-004 |
|
||||
| F09 | Отказ публикации: нет ложного `lastSuccessfulAt` | TC-META-002 |
|
||||
| F10 | Отказ сохранения sector/brand: поле и отсчёт прежние, финансовый успех цел | TC-META-003 |
|
||||
| F11 | Смена/удаление подключения: 404/409/400, sector/brand не переходят в новое | TC-META-005, TC-REG-004 |
|
||||
| F12 | Ошибка HTTP-чтения на клиенте: вкладка скрыта, данные не теряются | TC-CONC-002 |
|
||||
| F13 | Старый JSON без `sector`/`issuer` → `null`, группы «не указан» | PVΔ Scenario «Недоступность сектора и эмитента не нарушает Snapshot», Scenario «Позиции без эмитента в топе» |
|
||||
| F14 | Время sector/brand не освежает Snapshot; неполное `meta` — одно сообщение | TC-META-002; Scenario «Неполный Portfolio» |
|
||||
| F15 | Пустой разрез: нет NaN и фиктивных 100 % | Scenario «Валюта только у отрицательной позиции» |
|
||||
| F16 | Сбой дольше 24 ч: значение остаётся, повтор каждый обход | TC-META-003 |
|
||||
|
||||
Инварианты data-mapping: сумма разреза = сумма учитываемых позиций; доли в сумме
|
||||
ровно 100 % (точные); отсутствие подстановки нуля вместо `null` — точные доли Scenario «Расчёт долей» и прогоны TC-CONC-007/016.
|
||||
|
||||
## Test Data and Environment Needs
|
||||
|
||||
- **Портфель (мультивалютный, ≥10 позиций):** акции ≥7 (для «Прочее»), ровно 6 и 5
|
||||
вариантов; облигации; ≥2 фонда; позиция `CURRENCY`; `FUTURE`/`OTHER` для исключения.
|
||||
Валюты: RUB + USD (два Select-варианта), одиночный RUB-вариант отдельным набором.
|
||||
- **Стоимости:** положительные, 0, отрицательная, `null` (в т.ч. в невыполненной валюте);
|
||||
1 RUB против 100 000 000 RUB («< 0,1 %»); пары с равными суммами (граница топ-5).
|
||||
- **Бренды:** один `issuer.id` с разными `issuer.name` и разными суммами; равные суммы;
|
||||
два `issuer.id` с одинаковым `issuer.name`; акции + облигации одного бренда; позиции
|
||||
без бренда (группа «Эмитент не указан»).
|
||||
- **Секторы:** каждый из 11 кодов таблицы PC:374 (минимум `it`, `government`, `energy`),
|
||||
неизвестный код (`other`), пустой/пробельный, отсутствующий; один сектор у разных типов
|
||||
(STOCK+BOND+FUND); ≥6 секторов для «Прочее» в «Секторах».
|
||||
- **Upstream-моки (integration):** ответы T-Bank для всех 4 исходов таблицы «Результат получения сектора и эмитента»
|
||||
(значение / «не найдено» NOT_FOUND и пустой объект / чужой UID / UNAVAILABLE-timeout /
|
||||
PERMISSION_DENIED-token), частичный ответ, отказ публикации; `meta` неполноты.
|
||||
- Окружение: локальное backend + frontend с тестовым T-Bank-подключением; для Q-D1/Q-D2/Q-F3 —
|
||||
read-only доступ к реальному токену владельца.
|
||||
|
||||
## Manual / Exploratory Scope
|
||||
|
||||
- Соответствие макетам ux-design v2 (`visuals/`): кольцевая диаграмма, центр, бары строк,
|
||||
«Прочее», предупреждение — по всем четырём разрезам.
|
||||
- Контраст тёмной/светлой темы: сегменты, подписи, «< 0,1 %», сноска.
|
||||
- Клавиатура: Tab по переключателям разрезов, строкам и «Прочее»; Enter/Space раскрытие
|
||||
(PC:254), видимый focus, отсутствие «клик-ловушек».
|
||||
- Скринридер (VoiceOver/NVDA): текстовый эквивалент диаграммы — название, сумма, доля
|
||||
каждого сегмента в порядке списка; центр; сопоставление сегмент↔строка без цвета (PC:443).
|
||||
- Русская локаль чисел: неразрывный пробел, `₽`/`$`, запятая в долях.
|
||||
- Exploratory: поведение при переключении счёта, глубоких ссылок на вкладку (если есть),
|
||||
медленной сети при раскрытии «Прочее».
|
||||
|
||||
## Automation Candidates
|
||||
|
||||
- Компонентный вычислитель разреза (состав, ранжирование, тайбрейки, доли, границы 5/6/7,
|
||||
формат долей) — чистая функция от Portfolio: детерминированные оракулы из сценариев,
|
||||
максимум ценности на единицу поддержки.
|
||||
- Интеграционные проверки таблицы исходов «Результат получения сектора и эмитента» и 24h-логики (управляемое время) —
|
||||
регрессионно-критичны, зависят от классификации ошибок клиента.
|
||||
- e2e smoke: вкладка есть/нет, reset/persist состояния, отсутствие навигации по строкам,
|
||||
одно «Данные неполные».
|
||||
|
||||
## Regression Impact
|
||||
|
||||
| Область | Риск | Проверка |
|
||||
| --- | --- | --- |
|
||||
| Вкладка «Позиции» существующего счёта | Вкладки/состояния страницы изменены | TC-REG-001: прежний дефолт, состав и порядок вкладок |
|
||||
| PortfolioStructure («Структура портфеля») | Общий выбор валюты «протекает» на вкладку | TC-CONC-015; существующий regression-прогон PortfolioStructure |
|
||||
| GET-контракт Portfolio | Новые поля sector/issuer ломают клиентов; старый JSON | Существующий backend contract-прогон (OpenAPI); обратная совместимость ответа |
|
||||
| Часовой обход hourly-investment-sync | Новые вызовы сектора/бренда меняют нагрузку и успех обхода | TC-REG-004: успех/причины обхода без sector/brand-сбоев; F07/F11 |
|
||||
| Инвалидация token | Новое ветвление token-отказа | TC-META-002/005: 400 INVALID_TBANK_TOKEN во всех точках, замена/удаление подключения — 404/409 |
|
||||
|
||||
## Entry / Exit Criteria
|
||||
|
||||
Вход: G2 approved (есть); design готов или проверка не зависит от него (component-
|
||||
вычислитель и integration по спекам можно начинать); тестовые данные раздела выше доступны.
|
||||
Выход: все строки Coverage Matrix зелёные либо явные `not verified` ниже; все F01–F16
|
||||
покрыты; регрессия TC-REG-001/004 и существующие contract/regression-прогоны пройдены; ручной блок (макеты, контраст, клавиатура,
|
||||
скринридер) зафиксирован; findings по Q-D1/Q-D2/Q-F3 записаны.
|
||||
|
||||
## Gaps / Blockers
|
||||
|
||||
- not verified: соответствие квотам T-Bank при суточном обходе — риск деградации
|
||||
синхронизации; закрывается Q-F3 (измерение на реальном токене), норма исходов от
|
||||
результата не зависит.
|
||||
- not verified (до реализации): фактический вид «не найдено» у `EtfBy`/`BondBy`/`GetAssetBy`
|
||||
(Q-F3) — до подтверждения классификация NOT_FOUND в проверках задаётся моком по proto;
|
||||
расхождение с реальностью выявит Q-F3-проверка.
|
||||
- Открытый data-fact: Q-D1 (покрытие brand-пары) и Q-D2 (реальные `sector` фондов и
|
||||
госбумаг) — одноразовые read-only проверки на реальном Portfolio владельца; влияют на
|
||||
ожидаемый вид разрезов, не на нормы. Результат фиксируется в QA-отчёте change.
|
||||
- Пробелов в Specs, блокирующих проверку, нет (после G2 approved).
|
||||
@@ -0,0 +1,215 @@
|
||||
# Product Research
|
||||
|
||||
## Problem Statement
|
||||
|
||||
[F] Change Owner просит рядом с вкладкой «Позиции» показать «Топ-5»: крупнейшие акции, облигации и секторы по денежной оценке, переключение критерия и distribution chart; отдельно спрашивает, какой ещё критерий полезен, и формулирует эстетическую цель «сделать что-то классное и красивое» [R0].
|
||||
|
||||
[F] Сейчас страница счёта уже содержит donut «Структура портфеля» по классам активов с выбором одной валюты, вкладки и внутри «Позиций» переключатель «Акции / Облигации / Прочее» [L1, L2, L3].
|
||||
|
||||
[I] Исследовательский вопрос: какой узкий набор критериев даст владельцу понятный ответ «где сконцентрированы деньги», не дублируя текущую структуру по классам активов и не смешивая несопоставимые валюты.
|
||||
|
||||
[I] Документ является входным контекстом для продуктового решения, а не набором требований, контрактов или проектных решений.
|
||||
|
||||
## Target Audience
|
||||
|
||||
[F] Известная аудитория изменения — один владелец WealthLedger, использующий личный инвестиционный трекер поверх T-Bank Invest API [R0].
|
||||
|
||||
[H] Предположение о более широкой аудитории, частоте использования аналитики, размере портфеля, числе валют и уровне финансовой грамотности отсутствует: интервью, аналитика и обращения пользователей не предоставлены.
|
||||
|
||||
[I] Поэтому внешние продукты используются как источник кандидатов и паттернов, но не как доказательство спроса владельца WealthLedger.
|
||||
|
||||
## Research Questions
|
||||
|
||||
[I] 1. Какие разрезы концентрации и аллокации действительно встречаются в публично описанных портфельных продуктах, а какие остаются непроверенными кандидатами?
|
||||
|
||||
[I] 2. Какие критерии можно вычислить из текущего Portfolio Snapshot, а для каких в нём нет данных даже при наличии полей в интеграции T-Bank?
|
||||
|
||||
[I] 3. Какие формы визуализации подходят для рейтинга и для долей целого, как представлять «Top-N + Прочее» и насколько полезны доля топ-5, HHI и эффективное число позиций частному инвестору?
|
||||
|
||||
[I] 4. Как сохранить канонический принцип «без валютной конвертации» при сравнении позиций и категорий?
|
||||
|
||||
[I] 5. Где разместить новый обзор относительно существующих «Позиций» и «Структуры портфеля», чтобы назначение экранов не дублировалось?
|
||||
|
||||
## Existing Solutions and Alternatives
|
||||
|
||||
| Вариант | Что делает | Сильные стороны | Слабые стороны | Источник |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Оставить как есть | [F] Полный список позиций по типам и отдельный donut по классам активов уже доступны [L1, L2, L3]. | [I] Базовая альтернатива без нового дублирования и без новых данных. | [I] Не даёт явного рейтинга топ-5 и секторного разреза из запроса владельца. | [F] [L1, L2, L3] |
|
||||
| Т-Инвестиции: «Аналитика портфеля» | [F] Публичная справка описывает структуру по классам активов, секторам, валютам и компаниям; вкладка «Портфель» показывает доли компаний с акциями и облигациями одного эмитента вместе [E1]. | [I] Подтверждает ценность не только сектора, но и валюты и сквозной концентрации на эмитента. | [F] На проверенной странице не описаны топ-5, сроки погашения, кредитные рейтинги или HHI [E1]. | [F] [E1] |
|
||||
| Т-Инвестиции: доходность и выплаты | [F] Справка описывает прирост стоимости, дивиденды и купоны, а также график будущих выплат и стоимость портфеля на графике [E2]. | [I] Показывает соседний, но отличный от текущей концентрации сценарий — вклад в результат и доход. | [I] Эти данные не доказывают необходимость смешивания дохода и стоимости в одном переключателе «Топ-5». | [F] [E2] |
|
||||
| Portfolio Performance | [F] Statement of Assets содержит классификации, долю, стоимость, donut и treemap; документация называет asset class, sector, region, industry среди возможных классификаций, а Performance содержит показатели по бумагам и выплаты по инструментам [E6, E7, E8, E9]. | [I] Самый широкий проверенный набор разрезов и форм представления среди изученных источников. | [F] Taxonomy может требовать ручной классификации; публичная документация не доказывает, что все разрезы нужны данному владельцу [E7]. | [F] [E6–E9] |
|
||||
| Intelinvest | [F] Публичная продуктовая страница заявляет структуру по странам, секторам, регионам, компаниям и классам активов, а также аналитический доход по годам и месяцам [E3]. | [I] Подтверждает распространённость географии, отраслей и дохода в российском портфельном продукте. | [I] Это маркетинговое описание; детали расчёта, топ-5, облигационные корзины и accessibility на странице не проверены. | [F] [E3] |
|
||||
| Snowball Income | [F] Публичная страница описывает ребалансировку по секторам и типам активов с расхождением текущего и целевого распределения [E5]. | [I] Подтверждает сценарий сравнения структуры с целью, а не только наблюдения за текущими долями. | [I] Наличие топ-5, эмитентской концентрации, maturity/rating и HHI на проверенной странице не подтверждено. | [F] [E5] |
|
||||
| Sharesight, Morningstar X-ray, Kubera, Yahoo Finance, Finam | [I] Эти продукты рассматривались как кандидаты для выборки, но их конкретные критерии в доступных публичных страницах в ходе исследования не подтверждены. | [I] Не используются как доказательство «стандарта рынка». | [I] Не проверено; любые утверждения об их текущих разрезах потребуют отдельной проверки первичных источников. | [I] Нет подтверждённого источника по критериям |
|
||||
| Простейший новый вариант: ranked bar/list | [I] Пять строк с названием, суммой и долей, при необходимости шестая строка «Прочее». | [I] Наглядно сохраняет ранг, точные подписи и доступное текстовое представление; не зависит только от различения цветов. | [I] Менее «эффектный» вид и ограниченная компактность при длинных названиях. | [I] Интерпретация на основе [L4, E10] |
|
||||
| Donut «Top-5 + Прочее» | [F] Существующий виджет уже использует DonutChart, проценты/суммы в tooltip и текстовую легенду [L3]. | [I] Знакомый паттерн для долей целого и меньший риск визуального разнобоя. | [I] При повторении для классов активов будет дублировать виджет над вкладками; малые сектора труднее сравнивать по углам, чем по длинам. | [I] [L3] |
|
||||
| Treemap | [F] Portfolio Performance предоставляет treemap для иерархических классификаций активов [E6]. | [I] Компактно показывает много категорий и может выглядеть выразительно. | [I] Для пяти категорий избыточен, а малые прямоугольники затрудняют подписи и точное сравнение. | [I] [E6] |
|
||||
| Горизонтальная stacked bar | [I] Одна полоса показывает весь выбранный портфель и сегменты «Top-5 + Прочее». | [I] Компактная замена donut с явной общей суммой. | [I] Подписи малых сегментов и сравнение категорий остаются слабым местом; без текстовой легенды цвет становится единственным кодом. | [I] [E10] |
|
||||
|
||||
## Evidence & Sources
|
||||
|
||||
[F] **Проверенное ядро разрезов.** Текущие позиции, классы активов, секторы, география/регионы и валюты подтверждены хотя бы одним первичным продуктовым источником; эмитентская агрегация акций и облигаций дополнительно явно описана Т-Инвестициями [E1, E3, E6, E7].
|
||||
|
||||
[I] **Не универсальный стандарт.** Maturity buckets, кредитный рейтинг/risk level, вклад позиции в доход, top gainers/losers и HHI нельзя называть обязательным общим стандартом по этой выборке: либо найден только смежный отчёт, либо точный критерий не проверен.
|
||||
|
||||
[F] **Позиции по стоимости.** `PortfolioPositionDto` уже содержит `type`, `quantity`, `currentValue`, `displayName`, `ticker` и `absoluteReturn`; отдельных `sector`, `country`, `issuer`, `maturityDate` и `rating` в DTO нет [L4].
|
||||
|
||||
[I] **Акции и облигации — высокая готовность данных.** Рейтинг по `currentValue` внутри типа и выбранной валюты опирается на текущий snapshot; ограничения отсутствующих, нулевых или отрицательных оценок должны быть видимы, как и в существующей структуре [L3, L4].
|
||||
|
||||
[F] **Сектор и страна.** В интеграционных типах и proto T-Bank есть `sector`, `countryOfRisk`/`country_of_risk` и `currency`; `sector` присутствует у Share и Bond, а справка Т-Инвестиций предупреждает, что для российских бумаг встречаются отрасли и государственные ценные бумаги, а для иностранных — только отрасли [L5, L6, E1].
|
||||
|
||||
[I] **Сектор — средняя уверенность.** Поле существует upstream, но текущий Portfolio DTO его не exposes; полнота значений по реальным позициям владельца и применимость категории «государственные ценные бумаги» к секторному обзору не подтверждены [L4, L5, E1].
|
||||
|
||||
[I] **Страна/регион — кандидат на потом.** `countryOfRisk` доступен в интеграционных данных, но это страна риска, а не обязательно страна листинга, регистрации или валюты; регион потребует дополнительного смыслового отображения, которое в источниках не проверено [L5, L6].
|
||||
|
||||
[F] **Валюта.** Каноническая spec фиксирует Portfolio «без валютной конвертации» и итоги по валютам; существующая «Структура портфеля» уже показывает одну выбранную валюту за раз [L3, L8].
|
||||
|
||||
[I] **Валютный выбор — наиболее согласованный вариант.** Пер-валютный топ сохраняет сопоставимость сумм и существующий UX; единый топ или единый donut валютной аллокации по нескольким валютам требует общего знаменателя, то есть отдельного решения о конвертации и выхода за текущий узкий scope.
|
||||
|
||||
[F] **Эмитент.** В текущем DTO нет стабильного идентификатора эмитента; `displayName`, `isin` и `ticker` идентифицируют позицию/инструмент, но не доказывают принадлежность акции и облигации одной компании [L4].
|
||||
|
||||
[F] **Источник идентичности эмитента вне Portfolio Snapshot.** Полное описание актива T-Bank `AssetFull.brand` содержит бренд (`uid`, `name`, `company`, `sector`); у Share, Bond и Etf есть `asset_uid`, связывающий инструмент с активом. Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа [L9].
|
||||
|
||||
[I] **Эмитентская агрегация — полезный кандидат, источник идентичности есть.** Паттерн подтверждён Т-Инвестициями [E1]; бренд актива T-Bank (`AssetFull.brand`) даёт кандидата на идентичность эмитента вне текущего Portfolio Snapshot, где её нет [L4, L9]. Группировка по совпадающему имени остаётся ненадёжной эвристикой. Полнота бренда по позициям владельца не проверена [H].
|
||||
|
||||
[F] **Погашение и риск облигаций.** `TbankBond` содержит опциональные `maturityDate` и `riskLevel`; proto также описывает `perpetual_flag`, а текущий DTO этих полей не содержит [L4, L5, L6].
|
||||
|
||||
[I] **Maturity buckets возможны только для облигаций и требуют отдельной трактовки бессрочных бумаг и отсутствующих дат; `riskLevel` является полем T-Bank, а не подтверждённым кредитным рейтингом агентства [L5, L6].**
|
||||
|
||||
[F] **Кредитный рейтинг.** В проверенных текущих DTO, интеграционных типах и proto поле кредитного рейтинга не найдено; это наблюдение ограничено проверенными файлами, а не доказательством отсутствия любого рейтинга во всех продуктах T-Bank [L4, L5, L6].
|
||||
|
||||
[F] **Доход и P&L.** `absoluteReturn` уже есть у позиции, но текущий DTO не содержит дивидендный/купонный вклад; Portfolio Performance документирует отдельные performance и payments reports, а Т-Инвестиции отдельно описывают доходность и будущие выплаты [L4, E2, E8, E9].
|
||||
|
||||
[I] **Top gainers/losers по `absoluteReturn` технически ближе к MVP, чем income contribution, но отвечает на вопрос «что дало результат», а не «где сейчас деньги»; готовая постатейная история выплат в этом исследовании не проверена.**
|
||||
|
||||
[F] **Доступность цвета.** WCAG 2.2 SC 1.4.1 требует, чтобы цвет не был единственным визуальным средством передачи информации или различения элементов [E10].
|
||||
|
||||
[I] **Текстовая альтернатива обязательна для смысла диаграммы.** Название, сумма и доля в списке/легенде полезны не только для accessibility, но и для точного сравнения малых категорий [E10, L3].
|
||||
|
||||
[F] **Концентрация как риск.** FINRA описывает концентрацию как зависимость результата портфеля от одной позиции или группы связанных позиций и отмечает, что разные классы активов сами по себе не устраняют такую зависимость [E11].
|
||||
|
||||
[F] **HHI.** US DOJ определяет Herfindahl–Hirschman Index как сумму квадратов долей участников рынка; это источник математической формулы, а не нормы для личного инвестиционного портфеля [E12].
|
||||
|
||||
[I] **Доля топ-5 понятнее HHI для первого экрана.** Она прямо отвечает «сколько денег приходится на пять крупнейших позиций»; HHI и обратная величина `1 / Σw²` могут служить «эффективным числом позиций», но не имеют проверенного здесь персонального порога действия и создают риск ложной точности.
|
||||
|
||||
[I] **«Top-N + Прочее» сохраняет 100% выбранной совокупности и сокращает хвост, но скрывает состав «Прочего»; поэтому агрегат не заменяет полный список позиций.**
|
||||
|
||||
[F] **[R0]** Формулировка Change Owner и AS-IS контекст: Issue https://git.personal.ksv741.ru/WealthLedger/main/issues/48 (30.10.2026).
|
||||
|
||||
[F] **[L1]** `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx` — вкладки страницы счёта, размещение `PortfolioStructure`, `PortfolioStructure.key`, переключатель типов позиций.
|
||||
|
||||
[F] **[L2]** `frontend/src/pages/account/model/positions.ts` — виды секций `stocks | bonds | other`, отображение доли и результата позиции.
|
||||
|
||||
[F] **[L3]** `frontend/src/widgets/portfolio-structure/ui/PortfolioStructure.view.tsx` — DonutChart, выбор валюты, tooltip, легенда, обработка неполных/отрицательных/неоценённых данных.
|
||||
|
||||
[F] **[L4]** `backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts` — поля позиции и структуры портфеля.
|
||||
|
||||
[F] **[L5]** `backend/src/integrations/tbank/tbank-market.types.ts` — `sector`, `countryOfRisk`, `currency`, `maturityDate`, `riskLevel` и другие поля инструментов.
|
||||
|
||||
[F] **[L6]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — описание полей Share, Bond и связанных инструментов T-Bank.
|
||||
|
||||
[F] **[L7]** `backend/src/modules/investments/instruments/application/public-instrument.ts` — использование instrument metadata в публичном каталоге акций/облигаций, включая `sector`, `maturityDate`, `riskLevel`.
|
||||
|
||||
[F] **[L8]** `specs/openspec/specs/portfolio/portfolio-view/spec.md` — Portfolio Snapshot одного счёта, отсутствие валютной конвертации и итоги по валютам.
|
||||
|
||||
[F] **[L9]** `backend/src/integrations/tbank/proto/contracts/instruments.proto` — `AssetFull.brand` (~:1051-1067), `Brand` (:1220-1229), `asset_uid` у Share (:776), Bond (:543), Etf (:657); `Asset` (:1075-1080) без бренда; `BrandData` (`common.proto:120`) — только данные логотипа.
|
||||
|
||||
[F] **[E1]** Т-Банк, «Аналитика инвестиционного портфеля»: https://www.tbank.ru/invest/help/educate/yield-analysis/about/folio-lookup/ — структура портфеля по классам активов, секторам, валютам и компаниям.
|
||||
|
||||
[F] **[E2]** Т-Банк, «Доходность портфеля и аналитика в Т-Инвестициях»: https://www.tbank.ru/invest/help/educate/yield-analysis/ — стоимость, прирост, дивиденды, купоны и будущие выплаты.
|
||||
|
||||
[F] **[E3]** Intelinvest, официальная продуктовая страница: https://intelinvest.ru/ — заявленные разрезы структуры и аналитический доход; маркетинговый источник.
|
||||
|
||||
[F] **[E5]** Snowball Income, «Возможности»: https://snowball-income.com/overview — заявленная ребалансировка по секторам и типам активов; маркетинговый источник.
|
||||
|
||||
[F] **[E6]** Portfolio Performance Manual, «Statement of Assets»: https://help.portfolio-performance.info/en/reference/view/reports/statement/ — таблица, доли, donut, treemap и классификации.
|
||||
|
||||
[F] **[E7]** Portfolio Performance Manual, «Classifying your assets»: https://help.portfolio-performance.info/en/getting-started/classify-assets/ — taxonomy, asset class, sector, region, industry и ручная классификация.
|
||||
|
||||
[F] **[E8]** Portfolio Performance Manual, «Securities»: https://help.portfolio-performance.info/en/reference/view/reports/performance/securities/ — стоимость, прирост, прибыль/убыток и доходы по бумагам.
|
||||
|
||||
[F] **[E9]** Portfolio Performance Manual, «Payments»: https://help.portfolio-performance.info/en/reference/view/reports/performance/payments/ — выплаты по инструментам и суммы по типам платежей.
|
||||
|
||||
[F] **[E10]** W3C WAI, «Understanding SC 1.4.1: Use of Color»: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html — цвет не должен быть единственным носителем смысла.
|
||||
|
||||
[F] **[E11]** FINRA, «Concentrate on Concentration Risk»: https://www.finra.org/investors/insights/concentration-risk — риск зависимости от позиции или связанных позиций.
|
||||
|
||||
[F] **[E12]** US Department of Justice, «Herfindahl-Hirschman Index»: https://www.justice.gov/atr/herfindahl-hirschman-index — формула HHI и её исходная предметная область.
|
||||
|
||||
## Assumptions
|
||||
|
||||
[H] Под «крупнейшими» владелец имеет в виду наибольшую положительную `currentValue`, а не количество бумаг, прибыль, вес от всех счетов или целевую долю.
|
||||
|
||||
[H] Топ-5 рассматривается внутри одного Brokerage Account и одной выбранной валюты; включение cash и других счетов запросом не подтверждено.
|
||||
|
||||
[H] «Прочее» означает сумму позиций вне первых пяти, а не существующий тип позиций `other`; это разные понятия, которые нельзя смешивать без пояснения [L2].
|
||||
|
||||
[H] Владелец прежде всего хочет обзор текущей концентрации, а не рекомендации по ребалансировке, прогноз дохода или оценку кредитного качества.
|
||||
|
||||
[H] Фактическая полнота `sector`, `countryOfRisk`, `maturityDate` и `riskLevel` по позициям владельца неизвестна; реальных snapshot-данных в исследовании нет.
|
||||
|
||||
[H] Предпочтение «классное и красивое» не проверено отдельно; выбор конкретной диаграммы остаётся эстетической гипотезой, а не подтверждённой потребностью.
|
||||
|
||||
## Risks
|
||||
|
||||
[I] **Смешение валют.** Общий рейтинг без конвертации сравнит несопоставимые денежные единицы; конвертация изменит смысл канонического portfolio-view и расширит решение за пределы отображения топ-5 [L8].
|
||||
|
||||
[I] **Дублирование.** Новый donut по классам активов повторит существующий `PortfolioStructure`; название «Структура» дополнительно сделает границу двух блоков неочевидной [L1, L3].
|
||||
|
||||
[I] **Ложная полнота секторов.** Отсутствующий sector, специальная категория госбумаг и неприменимость сектора к части типов могут исказить доли, если не показывать неизвестную категорию [E1, L4].
|
||||
|
||||
[I] **Ошибочная идентичность эмитента.** Объединение по названию или тикеру может разделить связанные бумаги или объединить разные инструменты; текущий snapshot не содержит issuer identity [L4]; кандидат на стабильную идентичность — `AssetFull.brand` [L9].
|
||||
|
||||
[I] **Смешение задач.** Стоимость, unrealized P&L, исторические выплаты и будущие купоны имеют разные знаменатели и временной смысл; один безымянный переключатель критериев может создать видимость сопоставимости [E2, E8, E9].
|
||||
|
||||
[I] **Перегрузка MVP.** Страны, эмитенты, сроки, риск, доход, HHI и несколько типов диаграмм одновременно увеличат число непроверенных решений без данных об их ценности для единственного владельца.
|
||||
|
||||
[I] **HHI без действия.** Перенос антимонопольных порогов HHI на личный портфель был бы необоснованным; даже корректное число не подсказывает владельцу, какая концентрация приемлема именно для него [E11, E12].
|
||||
|
||||
[I] **Color-only и скрытый хвост.** Диаграмма без подписей нарушит принцип WCAG 1.4.1, а «Прочее» без суммы/доли и доступа к полному списку скроет значимую часть риска [E10].
|
||||
|
||||
## Conclusions
|
||||
|
||||
[I] **Высокая уверенность:** непосредственно запрошенные топ-5 акций и облигаций по текущей стоимости — самый обоснованный MVP: данные уже есть, результат не дублирует класс активов и решает конкретную задачу концентрации [L4].
|
||||
|
||||
[I] **Средняя уверенность:** секторный «Top-5 + Прочее» соответствует и запросу, и проверенным продуктовым паттернам, но качество зависит от непроверенной полноты upstream-данных [E1, E3, E6, L5].
|
||||
|
||||
[I] **Высокая уверенность:** выбор одной валюты за раз — единственный найденный вариант, который одновременно сохраняет текущий UX и каноническое отсутствие конвертации; общий мультивалютный топ без нового правила конвертации некорректен [L3, L8].
|
||||
|
||||
[I] **Средняя уверенность:** для топ-позиций ranked horizontal bar/list информативнее donut; donut допустим для секторных долей, если рядом есть текстовые суммы и доли, а существующий donut по классам активов не повторяется [L3, E10].
|
||||
|
||||
[I] **Средняя уверенность:** доля топ-5 — полезная простая метрика концентрации; HHI и эффективное число позиций лучше отложить, поскольку персональная интерпретируемость и порог действия не подтверждены [E11, E12].
|
||||
|
||||
[I] **Средняя уверенность:** следующими кандидатами логичны эмитентская концентрация и страна риска, а не кредитный рейтинг: первые подтверждены внешними паттернами и частично данными, второй не найден в проверенном текущем источнике [E1, E3, L4–L6].
|
||||
|
||||
[I] **Низкая уверенность в широте «рыночного стандарта»:** выборка первичных источников небольшая, несколько названных продуктов не проверены, а маркетинговые страницы описывают заявления производителей, а не фактическое использование.
|
||||
|
||||
[I] **Размещение:** рабочее имя «Концентрация» точнее охватывает позиции и секторы, чем «Топ-5»; «Структура» хуже из-за существующего одноимённого по смыслу виджета, а отдельная вкладка рядом с «Позициями» соответствует исходному запросу владельца [R0, L1, L3].
|
||||
|
||||
## Recommendation
|
||||
|
||||
[I] **Сузить scope до одного обзора текущей концентрации в выбранной валюте, а не строить универсальную аналитику портфеля.** Уверенность: высокая для стоимостного топа, средняя для секторов, низкая для дополнительных разрезов.
|
||||
|
||||
[I] **Must — рекомендуемый узкий MVP:** топ-5 акций и топ-5 облигаций по `currentValue`; топ-5 секторов плюс «Прочее»; сумма и доля каждой строки; доля совокупного топ-5; выбор одной валюты; ranked bar/list с текстовыми подписями и одной distribution-визуализацией без повторения классов активов.
|
||||
|
||||
[I] **Условие уверенности для секторов:** если реальные данные окажутся слишком неполными, секторный критерий разумно перенести в later, не подменяя неизвестные значения догадками; сам стоимостной топ от этого не зависит [L4, L5].
|
||||
|
||||
[I] **Later:** страна риска; агрегация акций и облигаций одного эмитента (на момент research — после подтверждения issuer identity; позднее Change Owner перенёс в MVP, так как источник найден в `AssetFull.brand` [L9]); maturity buckets и `riskLevel` только для облигаций; top gainers/losers как отдельный смысловой режим; income contribution после подтверждения истории выплат; HHI/эффективное число позиций после проверки понятности владельцу.
|
||||
|
||||
[I] **No для этого изменения:** кредитный рейтинг как обещанный критерий; скрытая валютная конвертация; повтор donut по классам активов; treemap только ради визуального эффекта; одновременный запуск всех перечисленных критериев.
|
||||
|
||||
[I] **Размещение:** предпочтительна отдельная вкладка рядом с «Позициями» с рабочим названием «Концентрация»; решение изменится, если владелец хочет именно короткий рейтинг без секторов — тогда название «Топ-5» станет точнее.
|
||||
|
||||
[H] **Открытый вопрос 1:** секторы нужны только по акциям или по всем инструментам, для которых T-Bank возвращает sector, включая облигации и категорию государственных бумаг?
|
||||
|
||||
[H] **Открытый вопрос 2:** подтверждает ли владелец пер-валютный показ вместо единого рейтинга с конвертацией, и какая валюта должна выбираться первоначально?
|
||||
|
||||
[H] **Открытый вопрос 3:** входят ли в топ только позиции с известной положительной стоимостью и нужно ли отдельно показывать число неоценённых позиций?
|
||||
|
||||
[H] **Открытый вопрос 4:** достаточно ли пяти строк плюс «Прочее», или владельцу важно раскрывать хвост без возврата к полному списку?
|
||||
|
||||
[H] **Открытый вопрос 5:** новый блок должен оставаться отдельным от текущей «Структуры портфеля» или в перспективе заменить/поглотить её?
|
||||
|
||||
[H] **Открытый вопрос 6:** нужна ли сейчас только текущая концентрация, или владелец ожидает также top gainers/losers и вклад дивидендов/купонов?
|
||||
|
||||
[H] **Открытый вопрос 7:** какие секторы и категории реально присутствуют в snapshot владельца и какая доля стоимости остаётся без sector?
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
# Системный анализ
|
||||
|
||||
## Контекст и статус
|
||||
|
||||
Цель — передать в Portfolio сектор инструмента и бренд его актива, чтобы frontend строил четыре разреза концентрации одного счёта без конвертации валют. Основание: `specs/openspec/changes/account-top-positions/proposal.md:11-18,60-63`; Issue [main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). Рабочая ветка specs — `feature/48-account-top-positions`.
|
||||
|
||||
Документ приведён в соответствие с решениями владельца [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081) и [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). G2 по комплекту Specs, system-analysis, data-mapping и failure-model approved (`process-record.md`, Gate Log, последняя строка G2). Технологии, внутренние компоненты и форма хранения здесь не проектируются.
|
||||
|
||||
Обозначения. **PC** — `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-concentration/spec.md`; **PVΔ** — `specs/openspec/changes/account-top-positions/specs/portfolio/portfolio-view/spec.md`. Requirement указываются по названию заголовка: `PC «Название»`, `PVΔ «Название»`. Пути с `backend/`, `frontend/`, `docs/`, `specs/` отсчитываются от workspace WealthLedger.
|
||||
|
||||
## Покрытие требований (Requirement Coverage)
|
||||
|
||||
Покрыты все 32 Requirement: 28 в PC и 4 в PVΔ (2 MODIFIED, 2 ADDED). Заголовки в первом столбце приведены дословно (проверено сопоставлением с файлами). Требования «Последнее известное значение сектора и эмитента» и «Ошибки получения сектора и эмитента» из Specs удалены: их содержание вошло в PVΔ «Свежесть и fallback полного Portfolio Snapshot» и PVΔ «Результат получения сектора и эмитента».
|
||||
|
||||
| Requirement | Flow / контракт | Модуль |
|
||||
| --- | --- | --- |
|
||||
| PC `Вкладка «Концентрация» страницы счёта` | F3: только страница одного счёта; `/all` вкладки не получает; вкладка по умолчанию прежняя | Hub |
|
||||
| PC `Вкладка в состояниях страницы` | F2 → F3: нет вкладки при загрузке, ошибке, недоступности и пустом Portfolio; есть при ≥1 позиции или денежном остатке; только остатки → пустой разрез | Hub; failure-model F12 |
|
||||
| PC `Переключение разрезов` | F3: четыре разреза одного ответа, один видимый; T-Bank не вызывается | Hub |
|
||||
| PC `Начальное состояние вкладки` | F3: разрез и валюта сохраняются, пока User на странице; сброс при новом заходе, переключении счёта и перезагрузке; на сервер не передаётся | Hub |
|
||||
| PC `Состав разрезов` | F3: Секторы — STOCK/BOND/FUND, Эмитенты — STOCK/BOND; `issuer` у FUND приходит в API, разрез его отбрасывает | data-mapping: «Применимость по типу», I3 |
|
||||
| PC `Названия элементов` | F2 → F3: displayName, подпись sector, `issuer.name`; группировка эмитента по `issuer.id` | data-mapping |
|
||||
| PC `Название эмитента при разных названиях бренда` | F3: имя — `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавитному порядку PC «Порядок элементов с равной суммой»; выбор на клиенте | data-mapping: I8 |
|
||||
| PC `Позиции, учитываемые в разрезе` | F3: только `currentValue` > 0 в выбранной валюте | data-mapping: I4 |
|
||||
| PC `Сноска о не учтённых позициях` | F3: null в любой валюте и отрицательные в выбранной | data-mapping: I12 |
|
||||
| PC `Одна валюта за раз` | F3: одна валюта на все разрезы, без конвертации | data-mapping: I11 |
|
||||
| PC `Доступные валюты вкладки` | F3: ненулевые оценки STOCK/BOND/FUND, ISO-порядок; при одной валюте или без валют выбора нет | data-mapping: I11 |
|
||||
| PC `Независимый выбор валюты вкладки` | F3: локальный выбор; пропавшая валюта → первая в списке | Hub |
|
||||
| PC `Топ-5 и строка «Прочее»` | F3: до 6 элементов — все строки; от 7 — пять и «Прочее» с числом остальных | data-mapping: I7 |
|
||||
| PC `Раскрытие строки «Прочее»` | F3: остаток уже входит в ответ; нового запроса нет | Hub |
|
||||
| PC `Порядок элементов с равной суммой` | F3: название без регистра: цифры, латиница, кириллица, затем порядок в Portfolio | data-mapping: I8 |
|
||||
| PC `Расчёт долей` | F3: знаменатель — сумма учитываемых позиций разреза | data-mapping: I5, I9 |
|
||||
| PC `Показ долей` | F3: формат карточек позиций, «< 0,1 %» | data-mapping |
|
||||
| PC `Суммарная доля топ-5` | F3: метрика только при ≥6 элементах | data-mapping: I7 |
|
||||
| PC `Группы «Сектор не указан» и «Эмитент не указан»` | F2 → F3: `null` образует равноправный элемент | data-mapping: I6; failure-model F06 |
|
||||
| PC `Подписи секторов` | F3: известный код → подпись, иначе код как получен | data-mapping |
|
||||
| PC `Русские подписи известных секторов` | F3: таблица подписей | data-mapping |
|
||||
| PC `Диаграмма распределения разреза` | F3: сегменты совпадают со списком; ≤6 элементов — по сегменту, ≥7 — пять и «Прочее» | data-mapping: I10 |
|
||||
| PC `Центр диаграммы` | F3: сумма разреза и подпись «Топ-5 · N %» при показе метрики | data-mapping: I10 |
|
||||
| PC `Строки без переходов` | F3: строки без переходов; в «Эмитентах» без логотипов; `logoName` остаётся в API | Hub |
|
||||
| PC `Доступность диаграммы без цвета` | F3: текстовый эквивалент и сопоставление сегмента со строкой | Hub; `ux-design.md` |
|
||||
| PC `Пустое состояние разреза` | F3: нет учтённых элементов — пустое состояние без доли и диаграммы | data-mapping: I14 |
|
||||
| PC `Предупреждение о неполных данных` | F2 → F3: `meta` обозначает неполноту — «Данные неполные» только на вкладке над разрезом; страница его не дублирует; позиции без оценки и отсутствие sector/issuer предупреждения не вызывают | failure-model F14; заметка для design ниже |
|
||||
| PC `Состояния Portfolio на вкладке «Концентрация»` | F2 → F3: состояния как на «Позициях»; отсутствие sector/issuer не скрывает вкладку и не меняет успешность и свежесть | failure-model F12, F14 |
|
||||
| PVΔ `Нормализованный состав Portfolio` (MODIFIED) | F1 → F2: аддитивные `sector`, `issuer`; значения только у STOCK/BOND/FUND, включая `issuer` фонда; обрезка пробелов; остальные типы `null`; UID → FIGI; исключение — отказ token | Hub: контракт; data-mapping; failure-model |
|
||||
| PVΔ `Свежесть и fallback полного Portfolio Snapshot` (MODIFIED) | F1, F2: два исключения — сектор и бренд хранятся как последнее известное значение (GET читает хранимое, T-Bank не вызывает); отказ token при получении обрабатывается как при обходе Portfolio | Hub; failure-model F08, F11 |
|
||||
| PVΔ `Обновление сектора и эмитента` (ADDED) | F1: обновление при первом обходе, начавшемся не ранее чем через 24 ч после последнего успешного получения; пока значение не получено — при каждом обходе | failure-model: «Таймауты и повторы» |
|
||||
| PVΔ `Результат получения сектора и эмитента` (ADDED) | F1: четыре исхода определяют поле и отсчёт 24 ч; неуспешное получение не мешает обновлению Portfolio | data-mapping; failure-model F02–F05 |
|
||||
|
||||
## Системный контекст и границы
|
||||
|
||||
Участники: T-Bank Invest API → backend часовой синхронизации → сохранённый Portfolio Snapshot → backend HTTP API → авторизованный frontend. T-Bank владеет исходной классификацией и брендами, WealthLedger — нормализацией и представлением. Подтверждено `docs/architecture/context.md:9-38`, `docs/architecture/containers.md:9-33`, `docs/integrations/tbank.md:3-21`.
|
||||
|
||||
Сектор и бренд получает сервер в часовом обходе, не браузер и не открытие вкладки. Обычный GET и его повтор не обращаются к T-Bank (`specs/openspec/specs/tbank/tbank-data-sync/spec.md:9-14`; PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Сектор и бренд читаются из хранимого значения»). MOEX, НКР и CDN логотипов не являются источниками sector/issuer.
|
||||
|
||||
**Владелец расчёта разрезов — frontend над полным ответом Portfolio.** Подтверждено: Change Profile — «вкладка считается по уже загруженному Portfolio» (`specs/openspec/changes/account-top-positions/process-record.md:16`), impact в `proposal.md:60`. Specs закрепляют клиентский расчёт: имя эмитента выбирается по позиции с наибольшим `currentValue` в выбранной валюте (PC «Название эмитента при разных названиях бренда»), а валюта и учитываемость зависят от выбора пользователя (PC «Позиции, учитываемые в разрезе», PC «Одна валюта за раз»). Серверный агрегат такой выбор не воспроизвёл бы без параметров запроса, которых контракт не вводит. Backend-агрегаты, endpoint топ-5 и обращение браузера к каталогу не требуются.
|
||||
|
||||
## As-Is / To-Be
|
||||
|
||||
### As-Is: фактический путь
|
||||
|
||||
1. Часовой планировщик независимо от экрана обновляет membership и Portfolio счетов (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:176-209`). Интервал и отсутствие перекрытий — `specs/openspec/specs/tbank/hourly-investment-sync/spec.md:8-21`.
|
||||
2. Для счёта параллельно запрашиваются GetPortfolio и GetPositions; после нормализации полный Snapshot публикуется, и только затем запрашивается обновление metadata позиций (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`). Регистрация metadata-запроса не ждёт внешнего lookup (`backend/src/integrations/tbank/instrument-metadata.service.ts:63-105`).
|
||||
3. Snapshot хранится одним JSON payload; публикация транзакционна и ограждена connection revision, attempt и доступностью счёта (`backend/prisma/schema.prisma:60-70`; `backend/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts:116-147`).
|
||||
4. Mapper считает `currentValue` из quantity × currentPrice и заполняет `logoName` из metadata (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31,63-99`). Это прецедент необязательного обогащения.
|
||||
5. GET читает сохранённый Snapshot и дополняет позиции сохранённой metadata без обращения к T-Bank (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:55-105,122-164`); время финансового Snapshot при этом не меняется.
|
||||
6. Общая metadata — GetInstrumentBy по UID, затем по FIGI. `Instrument` содержит `asset_uid`, но не `sector`; его `brand` — `BrandData` (логотип и цвета), а не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/proto/contracts/instruments.proto:939-998`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Локальный `TbankInstrument` не объявляет `assetUid` и `sector` (`backend/src/integrations/tbank/tbank.client.ts:91-99`).
|
||||
7. Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; это отдельный источник, а не готовое обогащение Portfolio (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:335-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23`).
|
||||
8. **Расхождения AS-IS с PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента», которые должна закрыть реализация.** (а) Общая metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`); норма требует срок от последнего успешного получения и четыре исхода. (б) Клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`); норма относит «не найдено» к исходу «значения нет». Это требования к реализации, а не выбор между вариантами.
|
||||
9. Frontend берёт Portfolio из generated paths существующего GET (`frontend/src/entities/portfolio/api/usePortfolio.ts:1-28`). Текущие DTO и опубликованный snapshot новых полей не содержат (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`; `frontend/src/shared/api/spec/openapi.json:6426-6519`; `frontend/src/shared/api/generated/schema.d.ts:1059-1085`).
|
||||
10. Общий справочник инструментов в AS-IS задокументирован как онлайн, без долговременного хранения (`docs/architecture/cross-cutting/data-freshness.md:9-12`, ADR 019); PVΔ «Свежесть и fallback полного Portfolio Snapshot» вводит исключение для сектора и бренда.
|
||||
|
||||
### To-Be: системные обязательства
|
||||
|
||||
- Каждая позиция содержит `sector` и `issuer`; значения только у STOCK, BOND, FUND, у остальных типов `null` (PVΔ «Нормализованный состав Portfolio»).
|
||||
- Получение — в часовом обходе: при первом обходе, начавшемся не ранее чем через 24 часа после последнего успешного получения; пока значение не получено — при каждом обходе (PVΔ «Обновление сектора и эмитента»). Поле и отсчёт 24 часов после каждого получения определяет таблица исходов (PVΔ «Результат получения сектора и эмитента»); здесь она не пересказывается.
|
||||
- Сектор и бренд хранятся как последнее известное значение; GET читает хранимое (PVΔ «Свежесть и fallback полного Portfolio Snapshot»).
|
||||
- Отказ token при получении обрабатывается как отказ token в обходе Portfolio (PVΔ «Свежесть и fallback полного Portfolio Snapshot»); иные ошибки на обновление Portfolio не влияют (PVΔ «Результат получения сектора и эмитента»).
|
||||
- Frontend получает полный список позиций и сам вычисляет разрезы; Portfolio не сортируется и не объединяется (PVΔ «Нормализованный состав Portfolio»).
|
||||
|
||||
Способ получения (типизированный запрос или ранее сохранённые данные), форма хранения времени успешного получения и порядок вызовов относительно публикации — design; нормы Specs от них не зависят, кроме того, что после отказа token подключение недействительно.
|
||||
|
||||
## Анализ влияния
|
||||
|
||||
| Область | Изменение / обязательство | Не меняется |
|
||||
| --- | --- | --- |
|
||||
| T-Bank → backend | Типизированный sector и asset → brand только для STOCK/BOND/FUND; источники-кандидаты в data-mapping | GetPortfolio/GetPositions, права подключения |
|
||||
| Часовой обход | Дополнительные чтения по инструментам и активам по сроку PVΔ «Обновление сектора и эмитента» | Интервал 60 минут, запрет перекрытия |
|
||||
| Сохранённый Snapshot и состояние получения | Новые nullable поля позиций; хранение последнего известного значения и времени последнего успешного получения (PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента») | Количество, деньги, порядок, `fetchedAt` финансового наблюдения |
|
||||
| Backend → frontend | Аддитивные поля `PortfolioPositionDto` в `/api/v1` | Envelope, параметры, прежние статусы |
|
||||
| Подключение | Отказ token при получении сектора/бренда делает подключение недействительным как при обходе Portfolio | Модель доступа |
|
||||
| Frontend | Четыре вычисляемых разреза над полным Portfolio; единственное сообщение о неполноте на вкладке (PC «Предупреждение о неполных данных») | Сводка, остальные вкладки |
|
||||
| Режим «Все счета» | Общая DTO получает те же поля; вкладки и расчёта нет | Агрегирование счетов |
|
||||
| API artifacts | backend OpenAPI → переданный snapshot → generated types | Автономность репозиториев |
|
||||
| Docs | Темы Portfolio и T-Bank, глоссарий, исключение из онлайн-справочника в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019 — по docs-impact | В этой задаче docs не редактируются |
|
||||
|
||||
Общая DTO для двух GET подтверждена `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31`. Подробности — [data-mapping](data-mapping.md) и [failure-model](failure-model.md).
|
||||
|
||||
### Заметка для design: дублирование предупреждения о неполноте
|
||||
|
||||
PC «Предупреждение о неполных данных» требует одно сообщение о неполноте: «Данные неполные» на вкладке, страница его не дублирует. В AS-IS страница уже показывает собственный Alert при неполноте:
|
||||
|
||||
- `frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:77-79` — `<Alert role="status">{t("investment.partial")}</Alert>` при `props.partial`; признак вычисляется как `portfolio.meta?.completeness === "incomplete"` (`frontend/src/pages/account/ui/AccountPage/AccountPage.vm.ts:124,130`);
|
||||
- Alert выводится над `<Tabs>` (`AccountPageLoaded.view.tsx:108-112`, неконтролируемый `defaultValue="positions"`), поэтому виден при любой вкладке, включая «Концентрация»;
|
||||
- текст Alert — «Период загружен не полностью» (`frontend/src/shared/i18n/locales/ru.ts:423`), не «Данные неполные».
|
||||
|
||||
Следствие: при открытой вкладке «Концентрация» и неполном Portfolio страничный Alert не должен показываться (иначе на странице два сообщения). Содержание других вкладок не меняется (PC «Вкладка «Концентрация» страницы счёта»). Состояние активной вкладки сейчас внутри `Tabs` и странице недоступно; как это обеспечить — design.
|
||||
|
||||
## Системы и ответственность
|
||||
|
||||
- T-Bank: инструмент, его сектор, связь с активом, идентичность и название бренда; допустимость token.
|
||||
- Backend sync: полный финансовый результат; получение sector/brand по PVΔ «Обновление сектора и эмитента» и PVΔ «Результат получения сектора и эмитента»; хранение последнего известного значения; обработка отказа token.
|
||||
- Backend HTTP: проверка доступа, выдача Portfolio с новым shape, явные `null` для старых Snapshot.
|
||||
- Frontend: вычисление разрезов, единый набор сумм для списка и диаграммы, выбор имени эмитента по PC «Название эмитента при разных названиях бренда», единственное предупреждение о неполноте.
|
||||
- Change Owner: принимает остаточные факты данных (Q-D1, Q-D2, Q-F3) как результат проверки при реализации; решений по ним не требуется.
|
||||
|
||||
## Владение данными
|
||||
|
||||
| Данные | Источник истины | Пишут | Читают |
|
||||
| --- | --- | --- | --- |
|
||||
| quantity, price, cash | T-Bank финансовые ответы | Backend публикует Snapshot | GET, frontend |
|
||||
| `sector` (STOCK/BOND/FUND) | `sector` Share/Bond/Etf в T-Bank, обрезанный по краям | Backend; хранимое последнее известное значение | Frontend: группирует по точному коду |
|
||||
| `issuer` (STOCK/BOND/FUND) | `AssetFull.brand.uid`/`name` в T-Bank, обрезанные по краям | Backend; пара целиком либо `null` | Frontend: группирует по `id`, имя по PC «Название эмитента при разных названиях бренда» |
|
||||
| Время последнего успешного получения sector/brand | Backend | Backend | Backend: условие PVΔ «Обновление сектора и эмитента» |
|
||||
| lastSuccessfulAt, isStale, `meta` | Подтверждённое финансовое наблюдение и состояние ресурса | Backend sync / local read | Frontend |
|
||||
| Разрезы, суммы, доли, топ-5, имя эмитента | Производные от одного ответа Portfolio | Frontend, только вычисляет | Список и диаграмма |
|
||||
| Русские подписи секторов | Таблица PC «Русские подписи известных секторов» | Frontend | Пользователь |
|
||||
|
||||
## Потоки взаимодействий
|
||||
|
||||
**F1 — часовой обмен.** Backend проверяет подключение и доступные счета → GetPortfolio + GetPositions → полный финансовый Snapshot. Для позиций STOCK/BOND/FUND, у которых значение не получено либо наступил срок PVΔ «Обновление сектора и эмитента», запрашиваются сектор и бренд актива; результат применяется по PVΔ «Результат получения сектора и эмитента»; отказ token делает подключение недействительным (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). Прочие сбои получения на финансовый результат не влияют.
|
||||
|
||||
**F2 — локальная выдача.** Авторизованный GET проверяет membership и connection → читает Snapshot и хранимые значения → `{data, meta}`. Для старого JSON без новых ключей возвращаются явные `null`; GET не обращается к T-Bank (PVΔ «Свежесть и fallback полного Portfolio Snapshot»). `data: null` — отсутствие полного снимка, а не пустая концентрация (`docs/domains/portfolio/flows/portfolio-loading.md:10-21`).
|
||||
|
||||
**F3 — клиентская проекция.** Из полного Portfolio frontend определяет список валют, выбирает разрез, отбирает позиции, агрегирует, ранжирует, выбирает имя эмитента и рассчитывает доли. Переключение разрезов и раскрытие «Прочее» новых запросов не требуют.
|
||||
|
||||
Диаграммы в отдельных файлах не создаются: работа ограничена тремя Markdown-файлами; три потока выше передают границы без внутреннего устройства.
|
||||
|
||||
## Контракты
|
||||
|
||||
| Контракт | Producer → Consumer | Версия / artifact | Совместимость | Опубликованный источник |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio`, `data.positions[]` | Backend → frontend | API v1; `PortfolioPositionDto` + delta ниже | additive | `frontend/src/shared/api/spec/openapi.json:6426-6519` — предоставленная версия |
|
||||
| GET `/api/v1/brokerage-accounts/all/portfolio`, общий shape позиции | Backend → существующие потребители | Тот же DTO; вкладка не применяется | additive | `backend/src/modules/investments/portfolio/presentation/portfolio.controller.ts:19-31` |
|
||||
| Instrument / Asset lookup | T-Bank → backend | `tinkoff.public.invest.api.contract.v1`, локальный `instruments.proto` | Внешний контракт не меняется; расширяется используемая часть | Источники-кандидаты в data-mapping |
|
||||
|
||||
### Семантическая delta `PortfolioPositionDto`
|
||||
|
||||
| Поле | Присутствие | Тип / ограничение | Значение |
|
||||
| --- | --- | --- | --- |
|
||||
| `sector` | Обязательно в каждой позиции | string или null | Код T-Bank без перекодирования, с обрезанными краями; только у STOCK/BOND/FUND; иначе `null` |
|
||||
| `issuer` | Обязательно в каждой позиции | object или null | `{id, name}` только у STOCK/BOND/FUND, включая FUND; иначе `null` |
|
||||
| `issuer.id` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.uid` | Не `asset_uid` и не UID инструмента |
|
||||
| `issuer.name` | Внутри непустого `issuer` | Непустая строка после обрезки: `brand.name` | Не `brand.company`, не название актива |
|
||||
|
||||
Пример позиции с данными: `{"sector":"financial","issuer":{"id":"brand-uid-example","name":"Сбер"}}`; идентификатор условный. Без данных: `{"sector":null,"issuer":null}`. Поля независимы. Значение из одних пробелов считается отсутствующим (PVΔ «Нормализованный состав Portfolio»).
|
||||
|
||||
Изменение добавляет только response-поля: запросы, Money, маршруты, статусы и версия API остаются. Текущий snapshot — OpenAPI **3.0.0** (`frontend/src/shared/api/spec/openapi.json:2`); синтаксис 3.1 молча не вводится. Таблица — семантическая delta, не полный машиночитаемый target OpenAPI. Протокол требует target-контракт в `analysis/contracts/` до реализации (`specs/openspec/schemas/wealth-ledger-sdd-lab/schema.yaml:383-393`); он не создан, так как работа ограничена тремя файлами. Для G2 нужен отдельный разрешённый шаг публикации и проверки target-контракта. После реализации backend-generated OpenAPI должен семантически совпасть с ним до G4.
|
||||
|
||||
### Публикация и получение API artifact
|
||||
|
||||
Backend генерирует и проверяет OpenAPI; потребителю явно передаётся новый artifact. Frontend обновляет versioned `src/shared/api/spec/openapi.json`, локально генерирует `src/shared/api/generated/schema.d.ts` и проверяет соответствие. Generated types вручную не правятся; frontend не читает backend checkout, runtime не требует Swagger. Основания: `specs/openspec/specs/backend-platform/api-contract-generation/spec.md:10-35`; `docs/architecture/http-api.md:5-22`; `frontend/.ai/conventions/data.md:15-34`.
|
||||
|
||||
## Семантика ошибок
|
||||
|
||||
Новых бизнес-ошибок HTTP из-за sector/issuer нет, кроме отказа token. Результат получения определяет PVΔ «Результат получения сектора и эмитента»; `200` с группами «не указан» не означает сбой Snapshot (PVΔ «Нормализованный состав Portfolio»; PC «Группы «Сектор не указан» и «Эмитент не указан»», PC «Состояния Portfolio на вкладке «Концентрация»»).
|
||||
|
||||
Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: `400 INVALID_TBANK_TOKEN` без fallback Snapshot (PVΔ «Свежесть и fallback полного Portfolio Snapshot», сценарий «Отказ token при получении бренда»; `specs/openspec/specs/portfolio/portfolio-view/spec.md:113-115`). Это соответствует текущему клиенту, сводящему PERMISSION_DENIED и UNAUTHENTICATED к InvalidToken (`backend/src/integrations/tbank/tbank.client.ts:571-575`).
|
||||
|
||||
Прочие ошибки доступа: нет подключения — `409 TBANK_NOT_CONNECTED`, недоступный счёт — `404` (`docs/domains/portfolio/flows/portfolio-loading.md:24-35`).
|
||||
|
||||
## Совместимость и миграция
|
||||
|
||||
1. **Старый потребитель → новый backend:** аддитивные JSON-поля не меняют существующий shape; известный потребитель — generated paths (`frontend/src/entities/portfolio/api/usePortfolio.ts:7-16`).
|
||||
2. **Новый backend → старый JSON Snapshot:** отсутствующие ключи выдаются как `null`; Snapshot не пересоздаётся, `lastSuccessfulAt` не меняется. Значения появляются после обхода, получившего их по PVΔ «Обновление сектора и эмитента». Текущий spread payload этого сам не делает (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:88-95`); механизм — design.
|
||||
3. **Новый frontend → старый backend или Client Cached Snapshot:** отсутствие ключей трактуется как неизвестные значения. Использование новых полей возможно после получения provider-first artifact.
|
||||
4. **Свежесть:** в обходах раньше срока значение не меняется (PVΔ «Обновление сектора и эмитента»); sector/brand не влияют на `isStale` (PVΔ «Нормализованный состав Portfolio»).
|
||||
5. **`/all`:** общий DTO проверяется, расчёта концентрации нет. Ломающих изменений для существующих потребителей нет.
|
||||
|
||||
## Подробные модули
|
||||
|
||||
- [data-mapping.md](data-mapping.md): источники полей, типы позиций, нормализация, инварианты, риски классификации.
|
||||
- [failure-model.md](failure-model.md): сценарии отказов со ссылками на нормы, лимиты, повторы, восстановление.
|
||||
- Отдельные nfr и observability не выбраны: нагрузка и частичное обогащение рассматриваются в failure-model без выдуманных SLA (`process-record.md:16-17`).
|
||||
- `ux-design.md` — представление вкладки; в этом комплекте не дублируется.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
### Закрыто решениями владельца
|
||||
|
||||
| Вопрос | Решение | Где в Specs |
|
||||
| --- | --- | --- |
|
||||
| `issuer` у фондов | Заполняется у STOCK/BOND/FUND; разрез «Эмитенты» фонды отбрасывает ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) | PVΔ «Нормализованный состав Portfolio»; PC «Состав разрезов» |
|
||||
| Имя при разных названиях одного `issuer.id` | Название позиции с наибольшим `currentValue`; при равных — по алфавиту (3081, [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) | PC «Название эмитента при разных названиях бренда» |
|
||||
| Пробелы | Края обрезаются; значение из одних пробелов отсутствует (3081) | PVΔ «Нормализованный состав Portfolio» |
|
||||
| Срок и поведение при ошибке | Раз в сутки от последнего успешного получения; исходы по таблице (3081, 3082) | PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента» |
|
||||
| Отказ token | Как отказ token в обходе Portfolio; прочие ошибки не мешают обновлению Portfolio (3081, 3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Результат получения сектора и эмитента» |
|
||||
| Хранение сектора и бренда вопреки «Свежесть и fallback» | Исключение для сектора и бренда (3082) | PVΔ «Свежесть и fallback полного Portfolio Snapshot» |
|
||||
| «Не найдено» от T-Bank | Как «значения нет» (3082) | PVΔ «Результат получения сектора и эмитента» |
|
||||
| Предупреждение о неполноте | Только на вкладке (3082) | PC «Предупреждение о неполных данных» |
|
||||
|
||||
### Остаётся неизвестным
|
||||
|
||||
Это факты данных T-Bank; они проверяются при реализации и решения владельца не требуют.
|
||||
|
||||
| ID | Что неизвестно | Чем проверяется |
|
||||
| --- | --- | --- |
|
||||
| Q-D1 | Реальное покрытие STOCK/BOND/FUND брендом актива; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | См. data-mapping: однократный read-only прогон на подключении владельца с агрегированной сводкой |
|
||||
| Q-D2 | Какие значения `sector` фактически отдаются для фондов и государственных облигаций | См. data-mapping: тот же прогон, список различных значений со счётчиками |
|
||||
| Q-F3 | Фактические квоты методов T-Bank, число различных инструментов и активов, вид ответа «не найдено» | См. failure-model: официальные лимиты Invest API, измерение числа вызовов в обходе, запрос с несуществующим `asset_uid` |
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Test Cases — conc (вкладка «Концентрация», разрезы, доли)
|
||||
|
||||
Cases пишутся только там, где нужны данные, шаги или oracle сверх Scenario;
|
||||
остальные строки Coverage Matrix в `../qa-analysis.md` ссылаются на Scenarios
|
||||
Delta Specs `portfolio-concentration` (PC) и `portfolio-view` (PVΔ).
|
||||
|
||||
### TC-CONC-002 Вкладка в состояниях страницы
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Вкладка в состояниях страницы` / все 3 Scenario
|
||||
- **Risk / Priority:** средний / high — вкладка не должна появляться при загрузке, ошибке и пустом Portfolio, но должна при Cash Balance
|
||||
- **Preconditions:** тестовый пользователь с тремя счетами (см. данные); frontend с управляемым API-моком
|
||||
- **Test Data:** счёт A — Portfolio пуст (нет позиций и остатков); счёт B — только Cash Balance 1 000 RUB; счёт C — одна акция; мок: GET `/portfolio/{C}` сначала pending, потом 500
|
||||
- **Steps:**
|
||||
1. Открыть счёт A → проверить набор вкладок.
|
||||
2. Открыть счёт B → открыть вкладку «Концентрация».
|
||||
3. Открыть счёт C при pending GET и при ответе 500.
|
||||
- **Expected Results:** A — вкладки «Концентрация» нет. B — вкладка есть, разрез «Акции» в пустом состоянии («Акции», RUB не показан — валют нет). C — в обоих случаях вкладки нет.
|
||||
- **Automation Candidate:** yes (component)
|
||||
|
||||
### TC-CONC-005 Сброс состояния при новом заходе и смене счёта
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` (норма + Scenario «Новый заход»)
|
||||
- **Risk / Priority:** средний / high — ошибочный persist состояния между заходами даёт чужой разрез/валюту
|
||||
- **Preconditions:** счёт с позициями в RUB и USD (доступные валюты: RUB, USD — первая RUB)
|
||||
- **Test Data:** портфель из TC-CONC-007; на вкладке выбраны «Секторы» и USD
|
||||
- **Steps:**
|
||||
1. Открыть счёт, вкладку «Концентрация», выбрать «Секторы», валюту USD.
|
||||
2. Перезагрузить страницу → открыть вкладку.
|
||||
3. Уйти со страницы счёта (список счетов) и вернуться → открыть вкладку.
|
||||
4. Переключить счёт на счёт C (одна акция) → открыть вкладку.
|
||||
- **Expected Results:** после шагов 2–4: разрез «Акции», выбрана первая валюта списка (RUB для этого счёта; для счёта C — валюта его акции).
|
||||
- **Automation Candidate:** yes (e2e)
|
||||
|
||||
### TC-CONC-006 Сохранение состояния при переключении вкладок страницы
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Начальное состояние вкладки` («Пока User остаётся на странице… разрез и валюта SHALL сохраняться»)
|
||||
- **Risk / Priority:** низкий / medium — поведение внутри одной сессии страницы
|
||||
- **Preconditions:** как TC-CONC-005
|
||||
- **Steps:**
|
||||
1. Вкладка «Концентрация»: выбрать «Облигации», USD.
|
||||
2. Переключиться на вкладку страницы «Позиции».
|
||||
3. Вернуться на «Концентрацию».
|
||||
- **Expected Results:** открыт «Облигации» в USD.
|
||||
- **Automation Candidate:** yes (e2e; объединяется с TC-CONC-005 в один прогон)
|
||||
|
||||
### TC-CONC-007 Состав разрезов по таблице типов
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Состав разрезов` (все ветви таблицы) + `Расчёт долей`
|
||||
- **Risk / Priority:** высокий / high — фильтр типов и агрегация определяют все цифры вкладки
|
||||
- **Preconditions:** вкладка открыта, валюта RUB
|
||||
- **Test Data (RUB, один счёт):** S1 `STOCK` 400, sector `energy`, issuer `b1` «Сбер»; S2 `STOCK` 100, sector `energy`, issuer `b1` «Сбербанк»; B1 `BOND` 100, sector `energy`, issuer `b1` «Сбер»; F1 `FUND` 300, sector `financial`, issuer `b1`; C1 `CURRENCY` 50; O1 `OTHER` 10
|
||||
- **Steps:**
|
||||
1. Разрез «Акции» → суммы и доли.
|
||||
2. Разрез «Облигации».
|
||||
3. Разрез «Секторы».
|
||||
4. Разрез «Эмитенты».
|
||||
- **Expected Results (расчёт):**
|
||||
- Акции: S1 400 ₽ «80 %», S2 100 ₽ «20 %» (база 500 = 400+100; 400/500 = 80 %, 100/500 = 20 %).
|
||||
- Облигации: B1 100 ₽ «100 %».
|
||||
- Секторы: «Энергетика» 600 ₽ (400+100+100) «66,7 %», «Финансы» 300 ₽ «33,3 %» (база 900; 600/900 = 66,666… % → «66,7 %», 300/900 = 33,333… % → «33,3 %»); C1 и O1 не входят.
|
||||
- Эмитенты: «Сбер» 600 ₽ «100 %» (400+100+100; фонд F1 исключён; имя — по наибольшей позиции S1 400 > B1 100 → «Сбер»).
|
||||
- **Automation Candidate:** yes (component, вычислитель разреза)
|
||||
|
||||
### TC-CONC-008 Цепочка названия элемента name → ticker → FIGI
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Названия элементов` + `portfolio-view` — `Нормализованный состав Portfolio` (отображаемый идентификатор)
|
||||
- **Risk / Priority:** средний / medium — пустое название ломает список
|
||||
- **Test Data:** акция A с name «Газпром»; акция B без name, ticker `SBER`; акция C без name и ticker, FIGI `BBG000123456`; позиция без issuer в «Эмитентах»
|
||||
- **Steps:**
|
||||
1. Разрез «Акции»: прочитать названия трёх строк.
|
||||
2. Разрез «Эмитенты» для позиции без issuer.
|
||||
- **Expected Results:** строки названы «Газпром», `SBER`, `BBG000123456`; в «Эмитентах» — «Эмитент не указан».
|
||||
- **Automation Candidate:** yes (component)
|
||||
|
||||
### TC-CONC-015 Независимость выбора валюты и fallback при исчезновении
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Независимый выбор валюты вкладки` (2 Scenario)
|
||||
- **Risk / Priority:** средний / high — общий Select протекает между виджетами; пропавшая валюта оставляет битое состояние
|
||||
- **Preconditions:** позиции в RUB и USD; «Структура портфеля» на той же странице
|
||||
- **Steps:**
|
||||
1. В «Структуре портфеля» выбрать USD; на вкладке «Концентрация» выбрать RUB.
|
||||
2. В «Структуре портфеля» сменить USD → RUB → USD.
|
||||
3. Обновить Portfolio моком так, чтобы позиций в RUB не осталось (только USD-акции).
|
||||
- **Expected Results:** после шага 2 на вкладке по-прежнему RUB. После шага 3 выбрана первая валюта нового списка — USD (единственная), разрез пересчитан по USD-позициям.
|
||||
- **Automation Candidate:** yes (component + e2e)
|
||||
|
||||
### TC-CONC-016 Границы 5/6/7 элементов и счётчик «Прочее»
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Топ-5 и строка «Прочее»`, `Суммарная доля топ-5`
|
||||
- **Risk / Priority:** высокий / high — граничные значения и формулировка числа элементов
|
||||
- **Preconditions:** возможность подмены портфеля (мок/API-фикстура)
|
||||
- **Test Data (RUB, суммы):** набор-1: 400, 250, 150, 100, 50, 30, 20; набор-2: 300, 250, 200, 150, 60, 40; набор-3: 300, 250, 200, 150, 100; набор-4: одна акция 100; набор-5: 12 акций, из них 7 в «Прочее»
|
||||
- **Steps:** для каждого набора открыть разрез «Акции», прочитать строки, счётчик «Прочее» и центр диаграммы.
|
||||
- **Expected Results (расчёт):**
|
||||
- Набор-1 (сумма 1 000): 5 строк + «Прочее» 50 ₽ (2 элемента) «5 %»; доли строк «40 %», «25 %», «15 %», «10 %», «5 %»; центр «Топ-5 · 95 %» и «1 000 ₽».
|
||||
- Набор-2 (сумма 1 000): 6 строк без «Прочее», доли «30 %», «25 %», «20 %», «15 %», «6 %», «4 %»; центр «Топ-5 · 96 %».
|
||||
- Набор-3 (сумма 1 000): 5 строк, доли «30 %», «25 %», «20 %», «15 %», «10 %», «Прочее» нет; центр без «Топ-5».
|
||||
- Набор-4: одна строка «100 %», центр только сумма.
|
||||
- Набор-5: счётчик «Прочее» — «7 элементов»; доли по фактическим суммам.
|
||||
- **Automation Candidate:** yes (component)
|
||||
|
||||
### TC-CONC-020 Формат и округление показанных долей
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Показ долей` (все ветви нормы)
|
||||
- **Risk / Priority:** средний / high — владелец сверяет доли с суммами; неверный формат подрывает доверие
|
||||
- **Preconditions:** разрез «Акции», RUB; управляемый портфель (мок/API-фикстура)
|
||||
- **Test Data (RUB, суммы):** набор-A: 1 и 15; набор-B: 100 000 000 и 1; набор-C: 100, 100, 100
|
||||
- **Steps:** для каждого набора прочитать показанные доли строк и их сумму.
|
||||
- **Expected Results (расчёт):**
|
||||
- Набор-A (база 16): точные доли 1/16 = 6,25 % и 15/16 = 93,75 %. Округление до десятых, середина вверх: «6,3 %» и «93,8 %». Сумма показанных 6,3 + 93,8 = 100,1 % ≠ 100 % — допустимо (компенсации нет).
|
||||
- Набор-B (база 100 000 001): 100 000 000/100 000 001 = 99,999999 % → «100 %» (без «,0»); 1/100 000 001 = 0,000001 % < 0,1 % → «< 0,1 %».
|
||||
- Набор-C: 100/300 = 33,333… % → у всех трёх «33,3 %» (сумма показанных 99,9 % — допустимо).
|
||||
- **Automation Candidate:** yes (component)
|
||||
|
||||
### TC-CONC-024 Полный проход таблицы подписей секторов
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Русские подписи известных секторов` (все 11 строк таблицы)
|
||||
- **Risk / Priority:** низкий / medium — таблица — эталон, одна ошибка портит разрез
|
||||
- **Test Data:** 11 позиций (BOND/STOCK/FUND) секторов `consumer`, `energy`, `financial`, `health_care`, `industrials`, `it`, `materials`, `real_estate`, `telecom`, `utilities`, `government` по 1 ₽ каждая — доли не важны, важны названия
|
||||
- **Steps:** открыть «Секторы», сверить каждую строку с таблицей PC:374.
|
||||
- **Expected Results:** подписи точно по таблице: Потребительский сектор, Энергетика, Финансы, Здравоохранение, Промышленность, Информационные технологии, Материалы, Недвижимость, Телекоммуникации, Коммунальные услуги, Государственный сектор.
|
||||
- **Automation Candidate:** yes (component, table-driven)
|
||||
|
||||
### TC-CONC-027 Доступность диаграммы скринридером и без цвета
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Доступность диаграммы без цвета`
|
||||
- **Risk / Priority:** средний / high (a11y) — информация только цветом недопустима
|
||||
- **Preconditions:** набор-2 из TC-CONC-016; VoiceOver (или NVDA); тема светлая и тёмная
|
||||
- **Steps:**
|
||||
1. Прочитать диаграмму скринридером в свёрнутом состоянии.
|
||||
2. Раскрыть «Прочее» (набор-1) и прочитать снова.
|
||||
3. Клавиатурой: Tab к переключателям разрезов и строкам, Enter на «Прочее».
|
||||
4. Сравнить контраст сегментов и подписей в обеих темах с макетами ux-design v2.
|
||||
- **Expected Results:** скринридер читает для каждого сегмента название, сумму и долю в порядке списка (для набор-2: шесть пар «название — N ₽ — N %»); раскрытие не меняет прочитанный состав (6 сегментов); Enter/Space раскрывают «Прочее», focus видим; контраст соответствует макетам.
|
||||
- **Automation Candidate:** no (ручная/visual; текстовый эквивалент — component)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Test Cases — meta (sector/issuer enrichment, Snapshot-инварианты)
|
||||
|
||||
Cases добавляют данные/порядок событий/oracle сверх Scenarios Delta Specs
|
||||
`portfolio-view` (PVΔ). Требуют управляемое время обхода (час) и управляемые
|
||||
ответы T-Bank (integration, configured HTTP tests, моки по proto).
|
||||
|
||||
### TC-META-002 Token-отказ при получении сектора/бренда не маскируется fallback
|
||||
|
||||
- **Verifies:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot` (исключение token) / Scenario «Отказ token при получении бренда»
|
||||
- **Risk / Priority:** высокий / high — invalid token, замаскированный старым Snapshot, скрывает от владельца потерю доступа
|
||||
- **Preconditions:** сохранённый полный Snapshot старше 60 минут; подключение с invalid token; GET выполняется после обхода
|
||||
- **Test Data:** мок T-Bank: GetPortfolio/GetPositions — PERMISSION_DENIED; типизированный инструмент (sector) — UNAUTHENTICATED
|
||||
- **Steps:**
|
||||
1. Дождаться обхода (или вызвать принудительно в тесте).
|
||||
2. GET Portfolio пользователем.
|
||||
3. Повторить с отказом только на `GetAssetBy` (бренд), финансы успешны.
|
||||
- **Expected Results:** шаг 2 — `400` Problem Details, code `INVALID_TBANK_TOKEN`, fallback Snapshot не возвращается. Шаг 3 — тот же `400`/`INVALID_TBANK_TOKEN`: отказ token на любом звене сектора/бренда приравнен к отказу обхода (требование «Свежесть и fallback полного Portfolio Snapshot»), хотя финансы могли быть получены.
|
||||
- **Automation Candidate:** yes (integration)
|
||||
|
||||
### TC-META-003 Суточный цикл обновления сектора
|
||||
|
||||
- **Verifies:** `portfolio-view` — `Обновление сектора и эмитента` / Scenario «Обход через 24 часа» + 24h-отсчёт таблицы `Результат получения сектора и эмитента`
|
||||
- **Risk / Priority:** высокий / high — ошибка отсчёта даёт вечное старое значение или вечный ре-fetch
|
||||
- **Preconditions:** управляемое время обхода (шаг «час»); позиция P; T-Bank меняет сектор P на `financial` сразу после T0
|
||||
- **Steps (линейная шкала):**
|
||||
1. T0: обход — успех, `sector: energy` (отсчёт с T0).
|
||||
2. T0+1h … T0+23h: обходы — обращений за сектором нет.
|
||||
3. T0+24h: обход запрашивает сектор, T-Bank отвечает UNAVAILABLE.
|
||||
4. T0+25h: обход запрашивает сектор (ошибка не сбросила отсчёт), T-Bank отвечает `financial`.
|
||||
5. T0+26h … T0+48h: обходы.
|
||||
6. T0+49h: обход.
|
||||
- **Expected Results:**
|
||||
- Шаги 2: `sector: energy`.
|
||||
- Шаг 3: `sector: energy` (последнее известное), отсчёт не сброшен (идёт от T0) — поэтому шаг 4 снова обращается.
|
||||
- Шаг 4: `sector: financial`, отсчёт с T0+25h.
|
||||
- Шаги 5: `sector: financial`, обращений нет (T0+25h+24h = T0+49h).
|
||||
- Шаг 6: первое обращение после успеха — сектор запрошен.
|
||||
- **Automation Candidate:** yes (integration, управляемое время)
|
||||
|
||||
### TC-META-004 Четыре исхода получения: поле и отсчёт 24 часов
|
||||
|
||||
- **Verifies:** `portfolio-view` — `Результат получения сектора и эмитента` (каждая строка таблицы) / Scenarios «Ответ без значения заменяет известное значение», «"Не найдено" перезапускает отсчёт», «Ответ о другом инструменте», «Недоступность T-Bank сохраняет известное значение», «Значение ещё не известно»
|
||||
- **Risk / Priority:** высокий / high — таблица — ядро нового контракта; классификация NOT_FOUND решает поведение
|
||||
- **Preconditions:** 4 позиции P1–P4, у каждой известен `sector: energy` (успех в T0); управляемое время; моки по proto
|
||||
- **Test Data:** P1 → ответ со значением `it`; P2 → NOT_FOUND (и вариант «пустой объект без sector» отдельным прогоном); P3 → ответ с чужим `asset_uid`/UID; P4 → UNAVAILABLE (timeout)
|
||||
- **Steps:**
|
||||
1. В T0+24h провести обход со всеми четырьмя моками.
|
||||
2. GET Portfolio, прочитать `sector` P1–P4.
|
||||
3. В T0+25h: P2 теперь отвечает значением `financial`; P3 и P4 — тоже успех `financial`.
|
||||
4. Обход T0+25h; GET; прочитать `sector`.
|
||||
5. Обход T0+48h: P1 и P2 — успех `financial`; P3 и P4 — `energy`.
|
||||
- **Expected Results:**
|
||||
- Шаг 2: P1 `it` (отсчёт сброшен на T0+24h), P2 `null` (сброшен на T0+24h), P3 `energy` (не сброшен, retry), P4 `energy` (не сброшен, retry).
|
||||
- Шаг 4: P2 — по правилу `Обновление сектора и эмитента` значение появляется только при обходе ≥24 ч после «не найдено» (T0+24h), поэтому в T0+25h остаётся `null`; P3 и P4 — `financial` (получено, сброс отсчёта на T0+25h).
|
||||
- Шаг 5: P1 — запрошен снова (отсчёт сброшен в T0+24h, T0+48h ≥ 24 ч), становится `financial`; P2 — запрошен (≥24 ч от «не найдено»), становится `financial`; P3 и P4 — не запрашиваются (последний успех T0+25h, T0+48h < T0+49h), `sector: financial` остаётся.
|
||||
- **Automation Candidate:** yes (integration; P2-ветка — минимальное воспроизведение перезапуска отсчёта)
|
||||
|
||||
### TC-META-005 Смена и удаление подключения: sector/brand не протекают
|
||||
|
||||
- **Verifies:** `portfolio-view` — `Свежесть и fallback` («прежние личные datasets недоступны…»), failure-model F11
|
||||
- **Risk / Priority:** высокий / high — утечка данных между подключениями
|
||||
- **Preconditions:** пользователь, подключения K1 (счёт A, позиции с sector/brand) и K2 (счёт B)
|
||||
- **Steps:**
|
||||
1. Заменить K1 на новое подключение K1' (новый token, те же счёта): GET счёта A.
|
||||
2. Удалить K1': GET счёта A.
|
||||
3. Поздняя публикация обхода K1 (симуляция задержанной попытки) — проверить счёт A и B.
|
||||
- **Expected Results:** шаг 1 — 404/409/400 по контракту замены, прежние datasets K1 недоступны; sector/brand позиции не выдаются до первого успешного обхода K1'. Шаг 2 — 404. Шаг 3 — публикация отклонена (`id`/`attemptId`/`running`), в Portfolio K2 и нового K1' данные K1 не появляются.
|
||||
- **Automation Candidate:** yes (integration)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Test Cases — reg (регрессия затронутых областей)
|
||||
|
||||
Проверки сохранения существующего поведения, изменённого рисками change.
|
||||
|
||||
### TC-REG-001 Существующая вкладка «Позиции» неизменна
|
||||
|
||||
- **Verifies:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта` («SHALL NOT менять вкладку по умолчанию и содержание остальных вкладок»); регрессионный baseline canonical `portfolio-view`
|
||||
- **Risk / Priority:** высокий / high — новая вкладка не должна ломать существующий экран
|
||||
- **Preconditions:** счёт с позициями и Cash Balance; вкладка «Позиции» открывается по умолчанию в canonical-поведении
|
||||
- **Steps:**
|
||||
1. Открыть страницу счёта: какая вкладка активна, состав и порядок вкладок.
|
||||
2. Пройтись по каждой прежней вкладке: содержимое и сортировка списка позиций, валютная сводка.
|
||||
3. Режим «Все счета»: проверить отсутствие «Концентрации» и целостность прежних вкладок.
|
||||
- **Expected Results:** по умолчанию открыта прежняя вкладка («Позиции»); «Концентрация» — рядом, последней из новых; содержание остальных вкладок и «Всех счетов» идентично canonical (сравнение по эталонным данным).
|
||||
- **Automation Candidate:** yes (e2e + существующий regression-прогон)
|
||||
|
||||
### TC-REG-004 Часовой обход: sector/brand не влияют на успех и нагрузку синхронизации
|
||||
|
||||
- **Verifies:** `hourly-investment-sync` (canonical) + PVΔ `Свежесть и fallback` / F07, F10, F14
|
||||
- **Risk / Priority:** высокий / high — новые RPC-вызовы в обходе могут ломать или замедлять финансовую синхронизацию
|
||||
- **Preconditions:** тестовое подключение; счёт из TC-CONC-007 (6 позиций, смешанные секторы/бренды); метрики `tbank.rpc.*`
|
||||
- **Steps:**
|
||||
1. Обход при полностью успешных ответах: события `tbank.rpc.*`, успех публикации, `lastSuccessfulAt`.
|
||||
2. Обход с тотальным сбоем sector/brand (UNAVAILABLE на все инструментальные вызовы), финансы успешны.
|
||||
3. Обход с RESOURCE_EXHAUSTED на `GetAssetBy` (после повторов клиента).
|
||||
4. Обход, где финансовые ответы не получены, но sector/brand успешны.
|
||||
- **Expected Results:** шаг 1 — Snapshot опубликован, `isStale: false`. Шаги 2–3 — Snapshot опубликован, успех/свежесть финансов не изменены («Результат получения сектора и эмитента»: неуспешное получение SHALL NOT мешать обновлению Portfolio), sector/brand — прежние известные. Шаг 4 — публикации нет, прежний Snapshot с `isStale: true` (F01: успех sector/brand не заменяет финансовый). Число новых RPC-вызовов за обход фиксируется как baseline для Q-F3.
|
||||
- **Automation Candidate:** yes (integration)
|
||||
@@ -0,0 +1,244 @@
|
||||
# UX Design
|
||||
|
||||
Visual Package: **v2** (2026-10-30), локальные SVG в `../visuals/`, хеши в разделе «Materials». v1 (четыре макета без центральной суммы, предупреждения и без разрезов с одной валютой) заменён.
|
||||
Базис существующих паттернов (код, не визуальные материалы): `frontend` @ `71126c91f0b30f4d65a313ddc7d7f86963340455`, Mantine / `@mantine/charts` 9.7.3, `docs/design/overview.md`. Storybook не запускался, истории прочитаны как исходники.
|
||||
|
||||
Поведение определяют Delta Specs `portfolio-concentration` (28 Requirements, с учётом решений владельца [#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) и `portfolio-view`. Документ ссылается на Requirements и не вводит нового поведения; всё, чего в Specs нет, вынесено в «Design-level items».
|
||||
|
||||
## User Goals
|
||||
|
||||
Единственный пользователь — владелец, который отвечает на вопрос «где сконцентрированы мои деньги» и сравнивает вклад бумаг, секторов и эмитентов. Цель владельца: «сделать что-то классное и красивое» без выхода за пределы дизайн-системы.
|
||||
|
||||
| Цель | Что должно быть видно за один взгляд |
|
||||
| --- | --- |
|
||||
| Понять степень концентрации | «Топ-5 · N %» и сумма разреза в центре кольца |
|
||||
| Узнать, кто в топе | Ранжированный список с названием, суммой, долей и длиной полосы |
|
||||
| Не потерять остальное | Строка «Прочее» с числом элементов и раскрытием |
|
||||
| Доверять цифрам | Одна валюта, предупреждение «Данные неполные», сноска «Не учтено позиций: N», одинаковые значения в кольце и списке |
|
||||
|
||||
## User Flows
|
||||
|
||||
Требования: `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`, `Вкладка в состояниях страницы`, `Переключение разрезов`, `Начальное состояние вкладки`, `Одна валюта за раз`, `Доступные валюты вкладки`, `Раскрытие строки «Прочее»`, `Строки без переходов`, `Сноска о не учтённых позициях`, `Предупреждение о неполных данных`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Страница счёта, вкладка «Позиции»] -->|Клик / Enter на «Концентрация»| B[Разрез «Акции», первая валюта по ISO]
|
||||
B --> C{Что делает User}
|
||||
C -->|Выбирает другой разрез| D[Разрез заменён, валюта сохранена, «Прочее» свёрнуто]
|
||||
C -->|Выбирает валюту, если валют две и больше| E[Пересчёт по позициям валюты, «Прочее» свёрнуто]
|
||||
C -->|Нажимает «Прочее»| F[Остальные элементы раскрыты, топ-5 и кольцо без изменений]
|
||||
F -->|Повторное нажатие| C
|
||||
C -->|Читает предупреждение и сноску| G[«Данные неполные» над разрезом; «Не учтено позиций: N» под списком]
|
||||
C -->|Переключает другую вкладку страницы| H[Разрез и валюта сохраняются]
|
||||
H -->|Возврат на «Концентрация»| C
|
||||
C -->|Уходит со страницы или переключает счёт| I[Состояние сброшено]
|
||||
I -->|Новый заход| B
|
||||
J[Режим «Все счета»] -.->|Вкладки нет| X((выход))
|
||||
```
|
||||
|
||||
1. **Открыть вкладку.** Вход: «Концентрация» в `Tabs.List` справа от «Позиции». Выход: разрез «Акции», первая валюта, кольцо с центром, список.
|
||||
2. **Переключить разрез.** Один шаг на `SegmentedControl`. Валюта остаётся, «Прочее» сворачивается.
|
||||
3. **Переключить валюту.** `Select`; пересчитывается текущий разрез, «Прочее» сворачивается. При одной валюте выбора нет, шаг пропускается.
|
||||
4. **Раскрыть «Прочее».** Нажатие на строку (мышь, касание, Enter/Space), повторное нажатие сворачивает.
|
||||
5. **Прочитать предупреждение и сноску.** Оба — текст без действий.
|
||||
6. **Вернуться с другой вкладки страницы.** Разрез и валюта прежние; **новый заход** (уход со страницы, смена счёта, перезагрузка) — снова «Акции» и первая валюта.
|
||||
7. **Пустой разрез.** User остаётся на вкладке и переключает разрез или валюту (если выбор показан).
|
||||
|
||||
## Information Architecture and Screen Structure
|
||||
|
||||
Положение: `Tabs.Tab value="concentration"` между «Позиции» и «Деньги». Вкладка не меняет `defaultValue` (`positions`); панель с `keepMounted={false}`, как у остальных. Требование сохранять разрез и валюту при переключении вкладок означает, что выбор живёт выше панели; способ — в `design.md`.
|
||||
|
||||
Порядок внутри панели:
|
||||
|
||||
1. **Предупреждение «Данные неполные»** — только при неполноте Portfolio, над карточкой (M5, M6). Один блок для всех разрезов: остаётся на месте при переключении разреза.
|
||||
2. **Карточка** (`Paper withBorder radius="lg"`): шапка, затем две колонки на десктопе / одна на мобильном.
|
||||
3. **Шапка.** `SegmentedControl` «Акции / Облигации / Секторы / Эмитенты» (aria-label «Разрез концентрации»). Справа подпись «Валюта» и `Select` — **только если валют две и больше**; иначе шапка содержит один переключатель (M5, M6).
|
||||
4. **Hero-блок** (подложка `blue.0`, градиент в белый, радиус `lg`): кольцо 220 px, толщина 32. **Центр:** вверху «Топ-5 · N %» (muted, только если элементов шесть и больше), под ним сумма разреза (bold). Без метрики — только сумма (M6). Под кольцом подпись «<Разрез> · <валюта>».
|
||||
5. **Ранжированный список** (`<ol>`): номер, маркер, [логотип], название и подпись, справа сумма и доля, полоса доли.
|
||||
6. **Строка «Прочее»** (при семи и более элементах): кнопка-disclosure с «N элементов», суммой, долей и шевроном. Раскрытие показывает остальные элементы вложенным списком (M2).
|
||||
7. **Сноска** «Не учтено позиций: N» (только при N > 0).
|
||||
|
||||
Содержимое строки по разрезам:
|
||||
|
||||
| Разрез | Логотип | Подпись под названием |
|
||||
| --- | --- | --- |
|
||||
| Акции | `PositionLogo` 40 px, без ссылки | тикер или идентификатор позиции |
|
||||
| Облигации | `PositionLogo` 40 px, без ссылки | ISIN |
|
||||
| Секторы | нет | нет |
|
||||
| Эмитенты | **нет** (`Строки без переходов`) | нет |
|
||||
|
||||
Логотип в «Секторах» не нужен: элемент объединяет позиции. Строка «Прочее» логотипа не имеет. Ни одна строка не ведёт на другую страницу; `PositionLogo` и название не получают `to`.
|
||||
|
||||
**Кодирование без цвета** (`Доступность диаграммы без цвета`, `Группы «Сектор не указан» и «Эмитент не указан»`):
|
||||
|
||||
| Признак | Кодирование |
|
||||
| --- | --- |
|
||||
| Ранг 1–6 | Цифра в строке и у сегмента (для сегментов ≥ 4 %) + монохромная синяя шкала `blue.9 → blue.0` с обводкой `blue.9` |
|
||||
| «Прочее» | Серая точечная штриховка в маркере, сегменте и полосе, серая обводка; слово «Прочее» |
|
||||
| «Сектор не указан» / «Эмитент не указан» | Диагональная штриховка поверх цвета ранга; название в тексте |
|
||||
| Значение | Всегда число в тексте строки; полоса и кольцо его дублируют |
|
||||
|
||||
Шкала однотонная, потому что `grape`, `orange` и `green` в «Структуре портфеля» означают классы активов (`portfolioCategoryPresentation`). Длина полосы — доля от 100 % суммы разреза, шкала совпадает с кольцом.
|
||||
|
||||
**Центр кольца.** Внутренний диаметр 156 px. «Топ-5 · N %» — 14 px / 600, сумма — 22 px / 700; сумма точная, без округления (`Центр диаграммы`), поэтому при длинной записи размер ступенчато уменьшается до 14 px (22 → 18 → 14), без переноса и обрезки. Обе строки входят в текстовый эквивалент.
|
||||
|
||||
## Interaction Behavior
|
||||
|
||||
| Элемент | Поведение |
|
||||
| --- | --- |
|
||||
| Переключение разреза | Fade + `translateY(4px)` 220 ms `ease-out`, как смена валюты в `PortfolioStructure`; при `prefers-reduced-motion: reduce` анимации нет. Вращения сегментов нет (`isAnimationActive: false`). Фокус остаётся на переключателе. |
|
||||
| Смена валюты | Та же анимация; «Прочее» сворачивается. Если выбранная валюта пропала после обновления Portfolio, выбирается первая (`Независимый выбор валюты вкладки`), без уведомления. Если валюта осталась одна, `Select` исчезает. |
|
||||
| Строка «Прочее» | `<button>` с `aria-expanded` и `aria-controls`; зона — вся строка, высота ≥ 44 px. Шеврон поворачивается на 180°, список раскрывается ≤ 200 ms (при reduce-motion — мгновенно). Фокус остаётся на кнопке. Пять строк и кольцо не меняются. |
|
||||
| Строки топ-5 и раскрытые элементы | Не интерактивны: курсор обычный, переходов нет (`Строки без переходов`). |
|
||||
| Hover/focus | Hover на сегменте подсвечивает строку и показывает tooltip «название · сумма · доля» (вид `PortfolioTooltip`); hover на строке подсвечивает сегмент. Это подсветка, не действие; вся информация доступна в тексте. |
|
||||
| Касание | Tooltip по касанию сегмента — дополнение; список читается без жестов. |
|
||||
| Фокус | Стандартное кольцо Mantine, не скрывается (показан на «Прочее» в M2). |
|
||||
| Предупреждение «Данные неполные» | Статический блок `Alert role="status"`, без кнопок; ручного обновления нет (`portfolio-view`). |
|
||||
|
||||
## UI States
|
||||
|
||||
Данные вкладки выводятся из уже загруженного Portfolio; собственных loading и error у вкладки нет. Состояния — только те, что есть в Specs: `Вкладка в состояниях страницы`, `Пустое состояние разреза`, `Предупреждение о неполных данных`, `Сноска о не учтённых позициях`, `Доступные валюты вкладки`, `Состояния Portfolio на вкладке «Концентрация»`.
|
||||
|
||||
| Экран / область | Loading | Empty | Error | Success | Partial / stale |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Страница счёта (Portfolio) | Skeleton страницы (`AccountPageLoading`); **вкладки «Концентрация» нет** | Штатный пустой Portfolio: **вкладки нет** (`AccountPageEmpty`) | `StateView error` (`AccountPageError`); **вкладки нет** | — | **Stale:** `SourceSyncStatus` над вкладками, как для «Позиций»; разрезы по сохранённому снимку. **Partial:** вкладка есть, над разрезом «Данные неполные» (M5, M6), разрезы по доступным данным |
|
||||
| Вкладка, разрез с данными | n/a | n/a | n/a | Центр, кольцо, список, сноска при N > 0 (M1–M3, M5, M6) | Сноска «Не учтено позиций: N». Позиции без оценки при полном Portfolio предупреждение «Данные неполные» не вызывают |
|
||||
| Разрез без учитываемых элементов (в том числе у счёта только с денежными остатками) | n/a | Название разреза и валюта (если валюта есть) вместо кольца, списка, метрики; `Select` (если показывается) и сноска остаются (M4) | n/a | n/a | Сноска при N > 0 |
|
||||
| Выбор валюты | n/a | Скрыт при одной валюте и при отсутствии валют | n/a | `Select` при двух и более | — |
|
||||
| Метрика «Топ-5 · N %» | n/a | Скрыта при пяти и менее элементах (M6) | n/a | Показана при шести и более (M1, M2, M5) | — |
|
||||
| «Прочее» | n/a | Строки нет при шести и менее элементах (M5) | n/a | Свёрнуто / раскрыто | — |
|
||||
| Группы «не указан» | n/a | n/a | n/a | Обычный элемент с названием и штриховкой (M2, M5) | Не влияют на успешность и свежесть Portfolio |
|
||||
|
||||
Disabled, offline, «нет прав»: не применимо (действий записи нет); недоступный счёт обрабатывает страница, вкладка в этих состояниях не показывается.
|
||||
|
||||
## Validation Feedback
|
||||
|
||||
Пользовательского ввода, кроме выбора из закрытых списков (разрез, валюта), нет; валидации и ошибок ввода нет. Обратная связь на выбор — мгновенная перерисовка (см. Design-level items про озвучивание).
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
Опора на существующую границу `48em`, уже применяемую в `PortfolioStructure.module.css`.
|
||||
|
||||
| Ширина | Раскладка |
|
||||
| --- | --- |
|
||||
| ≥ 48em, макеты M1, M2, M5 (1280) | Шапка в одну строку. Две колонки: hero 296 px + список, `gap` 32 (`xl`). Предупреждение (если есть) на всю ширину над карточкой. |
|
||||
| < 48em, макеты M3, M4, M6 (390) | Одна колонка: переключатель разрезов во всю ширину; при двух и более валютах ниже строка «Валюта + Select» (в M6 её нет); hero во всю ширину, кольцо 220 по центру; ниже список. Строка: логотип 40 (если положен), название и подпись слева, сумма и доля справа, полоса под ними. |
|
||||
| 320 px (минимум) | Переключатель разрезов — в контейнере с `overflow-x: auto` (паттерн `positionFilter`), подписи не сжимаются; страница не скроллится по горизонтали. |
|
||||
| Вкладки страницы на мобильном | Существующая `Tabs.List` (`flex-wrap: nowrap; overflow-x: auto`, класс `tabsList`) прокручивается по горизонтали. В макетах (M3, M4, M6) это показано так: вкладка «Операции» обрезана краем экрана, а не вмещена целиком, и справа 40 px градиентное затухание. Обрезанная вкладка — основной признак прокрутки; затухание — необязательное усиление (см. Design-level items). «Концентрация» — вторая вкладка и на 320 px видна целиком. |
|
||||
| Длинные названия | Название переносится до двух строк; суммы `white-space: nowrap`, `font-variant-numeric: tabular-nums`. Отсутствие названия — fallback из `Названия элементов`. |
|
||||
| Много элементов в «Прочее» | Вложенный список без собственной прокрутки. |
|
||||
| Масштаб текста 200 % | Колонки схлопываются в одну; числа не усекаются; сумма в центре кольца уменьшается ступенчато, но не обрезается. |
|
||||
|
||||
Целевые зоны переключателя, `Select` и «Прочее» ≥ 40–44 px (WCAG 2.2 AA 2.5.8: ≥ 24 px).
|
||||
|
||||
## Accessibility
|
||||
|
||||
Ориентир: WCAG 2.2 AA. Основа — `Доступность диаграммы без цвета` и `Центр диаграммы`.
|
||||
|
||||
| Элемент | Роль / имя |
|
||||
| --- | --- |
|
||||
| Панель | `Tabs.Panel`, имя от вкладки; внутри `<section aria-labelledby>` с заголовком «Концентрация» (visually hidden или `Title order={2}`) |
|
||||
| Предупреждение | `Alert role="status"` с текстом «Данные неполные», не `alert` (не прерывает) |
|
||||
| Переключатель разрезов | `SegmentedControl` (radiogroup), aria-label «Разрез концентрации» |
|
||||
| Валюта | `Select`, aria-label «Валюта концентрации» (отличается от «Валюта структуры портфеля»); при одной валюте элемента нет в DOM |
|
||||
| Кольцо | `role="img"`, имя «Распределение: <разрез>, <валюта>» + `aria-describedby` на скрытый текст: центр («Топ-5 · 86,5 %, 2 235 310 ₽» либо только сумма) и затем «1. Сбербанк, 612 480 ₽, 27,4 %; … Прочее, …» в порядке списка. Сегменты в порядок фокуса не входят |
|
||||
| Список | `<ol aria-label="<разрез>: топ элементов">`; каждый элемент читается как «название, сумма, доля» |
|
||||
| Полоса, маркер, штриховка | `aria-hidden` (дублируют текст) |
|
||||
| «Прочее» | `<button aria-expanded aria-controls>`, имя «Прочее, N элементов, <сумма>, <доля>» |
|
||||
| Сноска | Обычный текст, не `alert` |
|
||||
|
||||
**Клавиатура.** Порядок фокуса: вкладки страницы → разрез → валюта (если показана) → «Прочее» (если есть) → остальной контент. Строки не фокусируются, потому что на них нет действий. Enter/Space на «Прочее» переключает раскрытие; фокус при раскрытии не уходит. Прокручиваемый ряд вкладок на мобильном остаётся доступен с клавиатуры стрелками (поведение `Tabs`).
|
||||
|
||||
**Не только цвет.** Ранг — цифра + светлота + обводка; «Прочее» — точечная штриховка + слово; «не указан» — диагональная штриховка + слово; предупреждение — текст «Данные неполные».
|
||||
|
||||
**Контраст (расчёт по токенам `@wealthledger/tokens`, светлая схема)**
|
||||
|
||||
| Пара | Коэффициент | Требование |
|
||||
| --- | --- | --- |
|
||||
| text `dark.7` на белом | 15,5 : 1 | 4,5 |
|
||||
| text `dark.7` на `blue.0` hero | 14,3 : 1 | 4,5 |
|
||||
| muted `gray.7` на белом / на `blue.0` hero | 8,2 : 1 / 7,5 : 1 | 4,5 |
|
||||
| Обводка сегмента `blue.9` на белом | 10,4 : 1 | 3 (граница графики) |
|
||||
| Заливка `blue.6` полосы на треке `gray.1` | 4,7 : 1 | 3 |
|
||||
| Серый контур «Прочее» `gray.6` на белом | 3,3 : 1 | 3 |
|
||||
| Светлые заливки рангов 4–6 (`blue.3/1/0`) на белом | 1,8 / 1,2 / 1,1 : 1 | Информации не несут: различимы обводкой `blue.9` и цифрой |
|
||||
| `gray.6` для мелкого текста | 3,3 : 1 | Не использовать; `muted` в теме — `gray.7` |
|
||||
|
||||
**Тёмная тема.** `docs/design/overview.md`: «тёмная тема не заявлена», токены описывают только светлую схему; контраст для тёмной не рассчитывался. Цвета в реализации берутся из токенов / CSS-переменных; литералы в SVG нужны только для самодостаточности.
|
||||
|
||||
**Движение.** `prefers-reduced-motion: reduce` отключает fade, раскрытие и переходы подсветки.
|
||||
|
||||
**Увеличение текста.** До 200 % без потери информации.
|
||||
|
||||
## Design System Reuse
|
||||
|
||||
| Часть | Решение | Обоснование |
|
||||
| --- | --- | --- |
|
||||
| Вкладка | **Reuse** `Tabs.Tab` страницы счёта | Консистентная навигация |
|
||||
| Прокрутка вкладок на мобильном | **Reuse** `tabsList` (`overflow-x: auto`) | Уже работает; затухание у правого края в макете — необязательное улучшение |
|
||||
| Переключатель разрезов | **Reuse** `SegmentedControl` + обёртка с `overflow-x`, как у «Акции / Облигации / Прочее» | Тот же паттерн внутри вкладки |
|
||||
| Выбор валюты | **Reuse** `Select` (`allowDeselect={false}`, `w=112`, aria-label) из `PortfolioStructure`; скрывается при ≤ 1 валюте, как там | Единое поведение и вид |
|
||||
| Кольцо | **Reuse** `DonutChart` (size 220, thickness 32, `startAngle 90 / endAngle -270`, без анимации, tooltip по сегменту) | Знакомая форма |
|
||||
| Tooltip | **Reuse** `PortfolioTooltip` (`Paper`, `Text fw=600`) | Тот же вид |
|
||||
| Логотипы | **Reuse** `PositionLogo` (Avatar 40, `radius md`, fallback — первая буква) без `to` | Совпадает с `PositionCard`; переходов по Specs нет |
|
||||
| Предупреждение | **Reuse** `Alert role="status"` (как `investment.partial` страницы) | Тот же вид неполных данных |
|
||||
| Состояния страницы | **Reuse** `StateView`, `SourceSyncStatus`, `LoadingSkeleton` | Наследуются без изменений |
|
||||
| Анимация смены | **Reuse** keyframes `currency-change` | Единый ритм |
|
||||
| Центр кольца из двух строк | **Новый** («Топ-5 · N %» + сумма вместо одной `chartLabel`) | Требует `Центр диаграммы`; реализация — в `design.md` |
|
||||
| Строка рейтинга с полосой | **Новый** (номер, маркер, название, сумма/доля, полоса) | Нет компонента «строка доли»; `EntryCard` и `PositionCard` выражают карточку операции/позиции. Собирается из `Group`, `Text` и полосы |
|
||||
| Шкала рангов | **Новый** (монохромные `blue.9…0`) | Не смешивать с цветами классов активов; только палитра токенов |
|
||||
| Штриховки | **Новый** вспомогательный признак | `Доступность диаграммы без цвета`; название остаётся главным признаком |
|
||||
| Disclosure «Прочее» | **Новый** по структуре (`UnstyledButton` + `Collapse`) | В `@wealthledger/ui` аналога нет; поведение задано Specs |
|
||||
| Видимая подпись «Валюта» рядом с `Select` | **Отклонение** | В `PortfolioStructure` только aria-label; подпись поясняет значение на мобильном, aria-label сохраняется. Можно снять без последствий для Specs |
|
||||
| Подпись «<Разрез> · <валюта>» под кольцом | **Новый** текст | Помогает при одной валюте, когда `Select` скрыт; значений не добавляет |
|
||||
|
||||
Новые токены не вводятся; в `@wealthledger/ui` ничего не добавляется, предметные компоненты остаются в приложении (`docs/design/overview.md`).
|
||||
|
||||
## Materials
|
||||
|
||||
Относительные пути заданы от каталога этого файла. SVG самодостаточны (без scripts, handlers, внешних шрифтов и ресурсов; `font-family: system-ui, sans-serif`), содержат `<title>` и `<desc>` с текстовым эквивалентом, включая центр кольца. Данные синтетические; цвета в SVG — литералы токенов для самодостаточности.
|
||||
|
||||
PNG-превью не создавались: единственный доступный рендерер (`qlmanage`) выдаёт квадратные миниатюры с полями, непригодные как эталон.
|
||||
|
||||
| ID материала | Относительный путь / ссылка | Экран/состояние и назначение | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| M1 | `../visuals/desktop-stocks.svg` | Desktop 1280: «Акции», RUB, две валюты (выбор виден), 8 элементов: центр «Топ-5 · 86,5 %» + сумма, топ-5 с логотипами, «Прочее» свёрнуто, сноска «Не учтено позиций: 2» | `c4ef8f814f6b168d3d96b6b8288ee6c574b4cf361de451a2152b1e51c939dae5` |
|
||||
| M2 | `../visuals/desktop-sectors-expanded.svg` | Desktop 1280: «Секторы», «Прочее» раскрыто и в фокусе, «Сектор не указан» со штриховкой на 5-м месте, без логотипов, центр «Топ-5 · 92,1 %» + сумма | `b59256bbcca8a414af85b487966dbda314ca30bbe20c5a65cf313096b4305106` |
|
||||
| M3 | `../visuals/mobile-stocks.svg` | Mobile 390: «Акции», одна колонка, выбор валюты в отдельной строке, прокручиваемый ряд вкладок (обрезанная «Операции» + затухание) | `d0ed332a78bdf8c9646a3033db63fc45643b4f365b0fabf34f2433b815663dbd` |
|
||||
| M4 | `../visuals/mobile-empty-slice.svg` | Mobile 390: пустое состояние «Облигации» в RUB (валют две, выбор виден), сноска «Не учтено позиций: 1» | `04f8be877948c3a46f0cea64558c5da1a3577a3a4b947f32b7341f83eda0a377` |
|
||||
| M5 | `../visuals/desktop-issuers-warning.svg` | Desktop 1280: «Эмитенты», одна валюта (выбора нет), предупреждение «Данные неполные», шесть элементов без «Прочее», метрика «Топ-5 · 94,2 %» + сумма, без логотипов, «Эмитент не указан» со штриховкой | `ece4fa5499cd5c326569e3a3b3069546dc66964b9972cac65140449206fcb4ae` |
|
||||
| M6 | `../visuals/mobile-issuers-warning.svg` | Mobile 390: «Эмитенты», пять элементов: в центре только сумма (метрики нет), предупреждение «Данные неполные», без выбора валюты и без логотипов | `1380c45d3db93c9b778a77c210f4444a19fe6666dff8b32080312584b78719ae` |
|
||||
|
||||
Что проверено: валидный XML (`xmllint`), отсутствие scripts, `href`, `<image>` и `@font-face`, оценка наложений текста, расчёт контраста по токенам. Макеты не просматривались на отрисованном изображении. Единственное «выходит за экран» по оценке — «Операции» в M3, M4, M6, и это намеренная обрезка `clipPath`. Макеты — иллюстрация компоновки, а не пиксельная спецификация.
|
||||
|
||||
## Закрытые findings для Specs
|
||||
|
||||
Решения владельца ([#48, 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) внесены в Specs; UX-часть приведена в соответствие.
|
||||
|
||||
| ID | Решение | Requirement | Где отражено |
|
||||
| --- | --- | --- | --- |
|
||||
| S1 | Разрез и валюта сохраняются, пока User на странице, включая переключение вкладок; сбрасываются при новом заходе | `Начальное состояние вкладки` | User Flows 6, IA |
|
||||
| S2 | `Select` скрыт при одной валюте и без валют | `Доступные валюты вкладки` | IA 3, UI States, M5, M6 |
|
||||
| S3 | Метрика «доля топ-5» скрыта при пяти и менее элементах | `Суммарная доля топ-5` | IA 4, UI States, M6 |
|
||||
| S4 | Неполный Portfolio: вкладка есть, над разрезом «Данные неполные» | `Предупреждение о неполных данных` | IA 1, UI States, M5, M6 |
|
||||
| S5 | Вкладки нет при загрузке, ошибке и штатном пустом Portfolio | `Вкладка в состояниях страницы` | UI States |
|
||||
| S6 | Неизвестный сектор показывается как получено, без пояснений | `Подписи секторов` | Без UX-обработки; текст читается скринридером как есть |
|
||||
| S7 | «Прочее» показывает число остальных: «2 элемента», «5 элементов» | `Топ-5 и строка «Прочее»` | IA 6, M1, M2 |
|
||||
| S8 (часть) | Центр кольца: «Топ-5 · N %» и сумма разреза | `Центр диаграммы` | IA 4, Accessibility |
|
||||
| S9 | Без логотипов в «Эмитентах» и без переходов из строк | `Строки без переходов` | IA, Interaction Behavior, Design System Reuse |
|
||||
|
||||
Ранее предложенные вопросы Q1 (сумма разреза), Q3 (логотип эмитента), Q4 (переход из строки) закрыты теми же Requirements; Q8 (масштаб полосы от 100 %) принят как дизайн-решение.
|
||||
|
||||
## Design-level items
|
||||
|
||||
Поведения не меняют; остаются на уровне дизайна, подтверждаются в `design.md` / на ревью макетов. Пункты D1–D4 перенесены из прежних вопросов по просьбе владельца, D5–D7 найдены при обновлении.
|
||||
|
||||
| ID | Вопрос | Предложение по умолчанию |
|
||||
| --- | --- | --- |
|
||||
| D1 | Тексты пустого состояния. Specs задают содержание (название разреза и валюта, если есть), но не формулировку | Заголовок «<Разрез> в <валюта>: нет позиций» (без валюты, если валют нет); пояснение «Нет позиций с положительной оценкой в этом разрезе и выбранной валюте.» |
|
||||
| D2 | Озвучивание смены разреза и валюты (бывший S8) | Одна `role="status"` (polite) строка: «Разрез «Секторы», RUB: 8 элементов»; при первом показе не объявляется |
|
||||
| D3 | Минимальная видимая дуга для очень малых долей | Минимума нет (доли честные); сегмент опознаётся по списку и обводке |
|
||||
| D4 | Нумерация внутри раскрытого «Прочее» | Продолжение ранга (6, 7, …), как в M2 |
|
||||
| D5 | Сообщения о неполноте: на вкладке «Концентрация» страничное «Период загружен не полностью» не показывается, остаётся только «Данные неполные» на вкладке | Закрыт: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082); реализация — design.md F4 |
|
||||
| D6 | Затухание у правого края мобильных вкладок отсутствует в коде; ряд уже прокручивается и обрезанная вкладка сама показывает прокрутку | Затухание не обязательно; если принимается, оно общее для всех вкладок страницы, а не только для «Концентрации» |
|
||||
| D7 | Реализация двухстрочного центра `DonutChart` (`children` либо наложенный `Text`) и ступенчатое уменьшение суммы | Решает `design.md`; UX требует две строки, точную сумму без обрезки и текстовый эквивалент |
|
||||
@@ -0,0 +1,319 @@
|
||||
# Engineering Design
|
||||
|
||||
Design описывает, как backend и frontend реализуют утверждённые Specs и модули analysis для change `account-top-positions`. Обозначения **PC**, **PVΔ** — как в [system-analysis.md](analysis/system-analysis.md); Requirement указываются по названию заголовка. Пути с `backend/` и `frontend/` отсчитываются от workspace; база кода — backend `d37fa2c`, frontend `71126c9`. Design и [ux-design.md](analysis/ux-design.md) образуют один Design package с одним approval на G3. UX здесь не пересказывается: этот документ описывает только инженерную реализацию.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Specs (G2 approved): `specs/portfolio/portfolio-concentration/spec.md` (28 Requirement), `specs/portfolio/portfolio-view/spec.md` (PVΔ «Нормализованный состав Portfolio», PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента», PVΔ «Результат получения сектора и эмитента»).
|
||||
- Analysis (G2 approved): [system-analysis.md](analysis/system-analysis.md) (контракты, семантическая delta `PortfolioPositionDto`, заметка о дублировании Alert), [data-mapping.md](analysis/data-mapping.md) (M1–M3, источники-кандидаты в T-Bank, инварианты I1–I8), [failure-model.md](analysis/failure-model.md) (классификация исходов, F01–F16).
|
||||
- Design package (G3): [ux-design.md](analysis/ux-design.md), Visual Package **v2** (`visuals/*.svg`, SHA-256 — в ux-design «Materials»).
|
||||
- Параллельно с design: [qa-analysis.md](analysis/qa-analysis.md) и `analysis/test-cases/` (oracle для расчётов).
|
||||
- Ограничения: `backend/AGENTS.md` (integrations, data, http-api conventions), `frontend/AGENTS.md` (`.ai/architecture.md`, `.ai/conventions/ui.md`, `.ai/conventions/data.md`), ADR 015, 017, 019 (`docs/adr/`).
|
||||
- `analysis/contracts/` нет. Target-контракт — таблица «Семантическая delta `PortfolioPositionDto`» в system-analysis.
|
||||
|
||||
## Architecture Drivers
|
||||
|
||||
| ID | Driver | Источник |
|
||||
| --- | --- | --- |
|
||||
| D1 | Сектор и бренд хранятся как последнее известное значение. Обновление — при первом обходе, начавшемся не ранее чем через 24 ч после последнего **успешного** получения; пока значение не получено — при каждом обходе. Сектор и бренд независимы | PVΔ «Свежесть и fallback полного Portfolio Snapshot», PVΔ «Обновление сектора и эмитента» |
|
||||
| D2 | Четыре исхода получения: заменить, `null`, сохранить прежнее; отсчёт сбрасывается или не меняется | PVΔ «Результат получения сектора и эмитента», failure-model «Классификация» |
|
||||
| D3 | Отказ token при получении сектора или бренда обрабатывается как отказ token в обходе Portfolio: fallback не возвращается, `400 INVALID_TBANK_TOKEN` | PVΔ «Свежесть и fallback полного Portfolio Snapshot», failure-model F08 |
|
||||
| D4 | Прочие отказы не мешают публикации Snapshot и не меняют `isStale` или `lastSuccessfulAt` | PVΔ «Нормализованный состав Portfolio», PVΔ «Результат получения сектора и эмитента», F02–F12 |
|
||||
| D5 | GET не обращается к T-Bank | PVΔ «Свежесть и fallback полного Portfolio Snapshot»; ADR 019 |
|
||||
| D6 | Квоты T-Bank: каждый RPC проходит через общий RateLimiter (`TBANK_RATE_LIMIT_PER_SECOND`, по умолчанию 5/с) и bounded queue на 50 элементов | `backend/src/integrations/tbank/tbank.client.ts:147-190,52`; integrations convention; Q-F3 |
|
||||
| D7 | Аддитивный DTO: `sector`, `issuer{id,name}` присутствуют всегда и nullable; в старых Snapshot — `null` | system-analysis «Семантическая delta», «Совместимость» |
|
||||
| D8 | Точные суммы и доли (в сумме ровно 100 %), округление до десятых с серединой вверх, «< 0,1 %» | PC «Расчёт долей», PC «Показ долей», PC «Суммарная доля топ-5» |
|
||||
| D9 | Разрез и валюта сохраняются при переключении вкладок страницы и сбрасываются при новом заходе или смене счёта | PC «Начальное состояние вкладки» |
|
||||
| D10 | На вкладке «Концентрация» одно сообщение о неполноте; страница его не дублирует | PC «Предупреждение о неполных данных»; system-analysis «Заметка для design» |
|
||||
| D11 | Информация диаграммы передаётся не только цветом; есть текстовый эквивалент; в центре две строки | PC «Диаграмма распределения разреза», PC «Центр диаграммы», PC «Доступность диаграммы без цвета»; ux-design «Accessibility», D7 |
|
||||
| D12 | FSD-границы, вычисление в VM без effect, generated types берутся только из предоставленного snapshot | `frontend/.ai/architecture.md`, `ui.md`, `data.md` |
|
||||
|
||||
## Technical Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- получать сектор и бренд в существующем часовом шаге Portfolio без нового фонового процесса;
|
||||
- хранить их с возрастом каждого поля отдельно;
|
||||
- добавить два DTO-поля;
|
||||
- считать разрезы на клиенте чистой функцией с точной десятичной арифметикой.
|
||||
|
||||
**Non-Goals:**
|
||||
- менять общую metadata инструментов (`InstrumentMetadataService`) и её семантику свежести;
|
||||
- переписывать сохранённые Snapshot;
|
||||
- конвертировать валюты;
|
||||
- добавлять новые библиотеки, очереди, таймеры или процессы;
|
||||
- исправлять `formatShare` в карточке позиции (см. Risks);
|
||||
- менять каталог акций и облигаций.
|
||||
|
||||
## Implementation Impact Map
|
||||
|
||||
| Область | Затронута | Результат или причина N/A |
|
||||
| --- | --- | --- |
|
||||
| main | нет | Root содержит только workspace-навигацию; кода и контрактов там нет |
|
||||
| backend | да | Prisma-модель и аддитивная миграция; 3 метода `TbankClient` и opt-in обработка NOT_FOUND; `PositionClassificationService`; шаг в `PortfolioService.queueSnapshot` до publish; параметр начала обхода из `TbankHourlyScheduler`; DTO `sector`/`issuer`; нормализация старых Snapshot в `saved()` |
|
||||
| frontend | да | Новый openapi snapshot и `schema.d.ts`; `pages/account/model/concentration.ts`; state вкладки, разреза и валюты в `AccountPage.vm.ts`; управляемые `Tabs` и подавление Alert в `AccountPageLoaded`; view `Concentration/`; ключ `stock.sectors.government` в `ru.ts` |
|
||||
| specs | да | Этот `design.md`. Specs не меняются; findings — в разделе Risks. Contract Parity на G4 сравнивает generated OpenAPI с delta из system-analysis |
|
||||
| docs | да | Объём определяет docs-impact. Инженерные затронутые места: исключение из «справочник онлайн» в `docs/architecture/cross-cutting/data-freshness.md` и ADR 019, домен Portfolio (поля позиции, вкладка), страница интеграции T-Bank (ShareBy/BondBy/EtfBy/GetAssetBy), глоссарий |
|
||||
|
||||
## Decisions
|
||||
|
||||
### Backend
|
||||
|
||||
**B1. Отдельная таблица классификации, а не общий metadata-кэш** (D1, D2, D4)
|
||||
|
||||
- **Контекст.** Общий кэш хранит один JSON `TbankInstrument.metadata` из GetInstrumentBy. Он считает `fresh = complete` без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:295-323`). `Instrument` не содержит `sector`, а его `brand` — это `BrandData`, а не `Brand{uid,name}` (data-mapping «Что уже есть в коде»). Запрос metadata запускается асинхронно после publish и при каждой ошибке перезаписывает или отбрасывает весь payload (`instrument-metadata.service.ts:111-222`).
|
||||
- **Выбрано.** Новая глобальная (не персональная) таблица `TbankInstrumentClassification`. Состав — в разделе Data / Storage. Возраст сектора и бренда хранится отдельно: `sectorFetchedAt`, `brandFetchedAt`.
|
||||
- **Отвергнуто:** расширение metadata JSON и `selectMetadata`; переиспользование `TbankInstrumentSource`; хранение в payload Snapshot. Причины — в Solution Assessment.
|
||||
|
||||
**B2. Получение — синхронный шаг attempt Portfolio до publish** (D3, D4, D5)
|
||||
|
||||
Шаг встраивается в `queueSnapshot` (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:262-283`):
|
||||
|
||||
1. GetPortfolio и GetPositions.
|
||||
2. `normalize`.
|
||||
3. **`classification.refresh(token, positions, walkStartedAt)`** — новый шаг.
|
||||
4. `mapPortfolio` записывает `sector`/`issuer` из возвращённой Map в Snapshot.
|
||||
5. `repository.publish`.
|
||||
|
||||
Шаг стоит **до** publish, потому что `publish` переводит attempt в `complete` (`portfolio.repository.ts:121-131`), а `rejectMembershipToken` меняет только `running` attempt (`tbank-access.service.ts:233-251`). После publish отказ token не пометил бы подключение недействительным. До publish ошибка `InvalidToken` попадает в существующий `catch` (`portfolio.service.ts:285-291`): Snapshot не публикуется, подключение получает `invalidToken: true`, GET отвечает `400 INVALID_TBANK_TOKEN` через `TbankAccessService.local` (`tbank-access.service.ts:270-272`). Это ровно D3, новых путей ошибок нет.
|
||||
|
||||
Все остальные ошибки шага, включая ошибку записи в БД, ловит `refresh`. Он логирует событие `portfolio.classification.failed` только с `code` (без userId, токена и позиций) и возвращает ранее сохранённые значения. Publish продолжается (D4).
|
||||
|
||||
Отвергнуто: асинхронный шаг после publish по образцу metadata. Он требует отдельного durable work на каждый инструмент и отдельного fence для отказа token, а новых сведений не добавляет.
|
||||
|
||||
**B3. Начало обхода передаётся явно** (D1)
|
||||
|
||||
`TbankHourlyScheduler.walk` (`backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts:134`) фиксирует `walkStartedAt = new Date()` и передаёт его через `dispatchPortfolio` в `portfolio.request(userId, revision, accountId, true, walkStartedAt)` (`:280`). Сейчас других вызывающих `request` нет.
|
||||
|
||||
Правило: поле нужно получать, если `fetchedAt == null` или `walkStartedAt − fetchedAt ≥ 24 ч`. `fetchedAt` — момент получения ответа, как в сценариях PVΔ «Обновление сектора и эмитента». Фактический период обновления ≈ 25 ч принят владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)). Отвергнуто: брать время из durable-строки `TbankSyncWork`, потому что строка переиспользуется по `scopeKey` и не хранит время старта.
|
||||
|
||||
**B4. Вызовы T-Bank** (D2, D6)
|
||||
|
||||
- Новые методы `TbankClient`:
|
||||
- `getEtf` (`etfBy`, sparse service, как `getBond`);
|
||||
- `getAssetBrand(token, assetUid)` → `{assetUid, brand{uid,name}} | null` (`getAssetBy`);
|
||||
- необязательный параметр `notFound: true` в `getShare`/`getBond`/`getEtf`.
|
||||
|
||||
Существующие `getShare`/`getBond`/`getAssetDescription` остаются для каталога без изменений.
|
||||
- **NOT_FOUND.** Сейчас NOT_FOUND превращается в `NotFoundException` только для `shareBy` и `getInstrumentBy`; остальные методы получают Unavailable (`tbank.client.ts:577-583`). Приватный `call()` получает opt-in флаг, и условие становится `opt-in || shareBy || metadataCall`. Opt-in включают только новые вызовы классификации. Глобальное расширение списка методов отвергнуто: для `getAssetBy` оно превратило бы отсутствие описания в карточке компании в 404 вместо 503, а для `bondBy` поменяло бы ответ каталога облигаций. Это observable-изменения вне scope.
|
||||
- **Порядок для позиции STOCK/BOND/FUND:** `ShareBy`/`BondBy`/`EtfBy` по UID; при NOT_FOUND по UID и наличии FIGI — один повтор по FIGI (тот же порядок, что в PVΔ «Нормализованный состав Portfolio» и metadata). Это design-решение в рамках формулировки data-mapping «по instrument UID, затем по FIGI»: формулировка не уточняет, нужен ли переход на FIGI после NOT_FOUND по UID, и design его выбирает. Specs и data-mapping не меняются. Ответ даёт `sector` и `asset_uid`. Затем `GetAssetBy(asset_uid)`, если бренд нужно получать. Если инструмент в этом обходе не получен, используется сохранённый `assetUid`.
|
||||
- **Дедупликация.** Singleton-сервис держит Map in-flight promises: ключ инструмента для `*By` и `asset_uid` для `GetAssetBy`. Повтор внутри одного шага и параллельные attempt разных счетов с одним инструментом или активом делят один RPC. После завершения promise удаляется: это не кэш.
|
||||
- **Квоты.** Цикл последовательный (`await` на каждый инструмент), все RPC проходят через существующий `call()` с RateLimiter, retry и deadline. Вложенного retry нет. Переполнение очереди даёт Unavailable, то есть исход «иная ошибка». Ожидаемый объём — 2 RPC на инструмент раз в сутки плюс повторы при неуспехе; до 200 инструментов это около 80 с при 5/с, в пределах `WORK_WAIT_BOUND_MS` 15 мин (`tbank-hourly.scheduler.ts:30`).
|
||||
|
||||
**B5. Отображение исходов** (D2; failure-model «Классификация»)
|
||||
|
||||
| Наблюдение | Действие с полем | `*FetchedAt` |
|
||||
| --- | --- | --- |
|
||||
| Непустое значение после `trim`; для бренда — непустая пара `uid`/`name` | Записать | `now` |
|
||||
| `NotFoundException`, пустое поле, нет `asset_uid`, нет актива или бренда, неполная пара | `null` | `now` |
|
||||
| `uid`/`figi` ответа ≠ запрошенному; `asset.uid` ≠ `asset_uid` | Не менять | Не менять |
|
||||
| Unavailable, queue full, ошибка декодирования, ошибка БД | Не менять | Не менять |
|
||||
| `InvalidToken` | Пробросить (B2) | — |
|
||||
|
||||
Типы `OTHER`, `CURRENCY` и прочие не запрашиваются: `sector`/`issuer` = `null` (PVΔ «Нормализованный состав Portfolio»). Для FUND `issuer` заполняется, и разрез «Эмитенты» его отбрасывает на клиенте (PC «Состав разрезов»).
|
||||
|
||||
**B6. DTO и OpenAPI** (D7)
|
||||
|
||||
- `PortfolioPositionDto` (`backend/src/modules/investments/portfolio/presentation/portfolio.dto.ts:7-55`) получает поля:
|
||||
- `@ApiProperty({ type: String, nullable: true }) sector`;
|
||||
- `@ApiProperty({ type: PortfolioIssuerDto, nullable: true }) issuer`, где у `PortfolioIssuerDto` обязательные `id` и `name`.
|
||||
|
||||
Используется тот же шаблон nullable-объекта, что у `currentValue: MoneyDto | null`. Это OpenAPI 3.0, синтаксис 3.1 не вводится.
|
||||
- Backend: `npm run openapi:generate` записывает корневой `openapi.json` (не в Git). Frontend по правилу `frontend/.ai/conventions/data.md` «Типы и contract» получает artifact явно: он кладётся в `frontend/src/shared/api/spec/openapi.json` в frontend PR, затем `npm run openapi:generate` и `npm run openapi:check`. Frontend не читает backend checkout.
|
||||
|
||||
**B7. Старые Snapshot** (D7)
|
||||
|
||||
`PortfolioService.saved()` при чтении payload (`portfolio.service.ts:90`) нормализует каждую позицию: `sector: p.sector ?? null`, `issuer: p.issuer ?? null`. Сохранённые payload не переписываются. Следующий успешный часовой обход публикует Snapshot с полями. Data-миграции нет.
|
||||
|
||||
### Frontend
|
||||
|
||||
**F1. Чистая функция разрезов в `pages/account/model/concentration.ts`** (D8, D12)
|
||||
|
||||
- **Слой.** Вкладка нужна только странице счёта, поэтому `pages/account`, а не widget или entity (`frontend/.ai/architecture.md:32`).
|
||||
- **Сигнатура.** `toConcentration(portfolio, slice, currency)` → `{ currencies, items, other, total, top5Share, unaccounted, empty }`.
|
||||
- **Математика.** Используются только `decimalFromString`/`sumDecimals`/`decimalToString`/`formatDecimal` из `@/shared/lib` и константы масштаба по образцу `positions.ts` (`PERCENT_SCALE`, `formatPositionReturnPercent` с округлением середины вверх на bigint, `frontend/src/pages/account/model/positions.ts:40-69`). Доля считается как bigint-частное с запасом масштаба; показ — округление до десятых с серединой вверх; положительная доля < 0,1 даёт «< 0,1 %». Доля «Прочее» и «Топ-5» — частное от точной суммы, поэтому сумма точных долей = 100 % по построению.
|
||||
- `formatShare` из `entities/portfolio/model/display.ts` не используется: он считает через `Number`.
|
||||
- Порядок равных сумм (PC «Порядок элементов с равной суммой») и выбор имени эмитента (PC «Название эмитента при разных названиях бренда») реализуются внутри модели. Тесты — по oracle из `analysis/test-cases/`.
|
||||
|
||||
**F2. Композиция** (PC «Вкладка «Концентрация» страницы счёта», PC «Вкладка в состояниях страницы»)
|
||||
|
||||
- В `AccountPageLoaded` (`frontend/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx:108-112`) добавляется `Tabs.Tab value="concentration"` сразу после «Позиции», только при `accountId !== "all"`. Состояние `loaded` уже исключает загрузку, ошибку и пустой Portfolio (`AccountPage.vm.ts:110-121`), поэтому PC «Вкладка в состояниях страницы» выполняется без новой логики.
|
||||
- Панель — `components/Concentration/Concentration.view.tsx` (+ `.module.css`). View получает готовую модель и именованные действия; data hooks внутри view нет (`ui.md` «Container, VM и view»).
|
||||
|
||||
**F3. State в page VM** (D9, D10)
|
||||
|
||||
- `useAccountPageViewModel` владеет `{ accountId, tab, slice, currency }` в одном `useState`. Если сохранённый `accountId` ≠ текущему, VM при render выдаёт значения по умолчанию (`positions`, `stocks`, первая валюта) без effect. Так смена счёта считается новым заходом. Повторное открытие маршрута монтирует страницу заново.
|
||||
- `Tabs` становятся управляемыми (`value`/`onChange`), `keepMounted={false}` сохраняется: state живёт выше панели.
|
||||
- Если выбранной валюты нет в новом `currencies` (данные обновились), модель берёт первую валюту.
|
||||
- `toConcentration` вызывается в VM только при `tab === "concentration"`. Мемоизация не нужна: функция линейна по числу позиций.
|
||||
|
||||
**F4. Подавление страничного Alert** (D10)
|
||||
|
||||
В `AccountPageLoaded.view.tsx:77-79`: `props.partial ? (tab !== "concentration" && <Alert …investment.partial/>) : <CurrencyCards/>`. На вкладке показывается собственный `Alert role="status"` «Данные неполные» (PC «Предупреждение о неполных данных»). Остальные вкладки не меняются. Карточки валют при неполноте не показываются и на этой вкладке, как в AS-IS. Источник: PC «Предупреждение о неполных данных», решение владельца [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082).
|
||||
|
||||
**F5. DonutChart без прямого recharts** (D11; ux-design D7)
|
||||
|
||||
Проверено на `@mantine/charts` 9.7.3 (`frontend/package.json:41`, `node_modules/@mantine/charts/lib/DonutChart/DonutChart.d.ts:28-64`):
|
||||
|
||||
- `chartLabel` выводит только одну строку `<text>`. Поэтому центр — HTML-оверлей `Text` в `position: relative` контейнере поверх `DonutChart`: две строки, точная сумма без обрезки, ступенчатое уменьшение шрифта через CSS. Оверлей помечен `aria-hidden`, текстовый эквивалент даёт `aria-describedby`.
|
||||
- `cellProps(series)` пробрасывает SVG-атрибуты в каждую ячейку (`node_modules/@mantine/charts/esm/DonutChart/DonutChart.mjs:51,72-78`: `cellProps` передаётся в построение `pieData`, которое уходит в `<Pie data>`). Отсюда задаются обводка и `fill="url(#…)"` для штриховки «Прочее» и «не указан». Узоры (`<pattern>`) лежат в отдельном скрытом `<svg width=0 height=0 aria-hidden>` в той же панели: ссылки `url(#id)` действуют в пределах документа, id берутся из `useId`.
|
||||
- Цифры ранга на сегментах ≥ 4 % рисует `pieProps.label` (render-функция). `pieProps` разворачивается после встроенного `label`, поэтому он его переопределяет.
|
||||
- `role="img"`, `aria-label`, `aria-describedby` передаются через `...others` на root `Box`.
|
||||
- `name` в data — уникальный ключ строки; тип ячейки находится по ключу в замыкании `cellProps`.
|
||||
|
||||
Прямой `recharts` (уже зависимость 3.10.1) не нужен. Отвергнуто: собственный SVG-donut — лишний код без driver.
|
||||
|
||||
**F6. Подписи секторов** (PC «Подписи секторов», PC «Русские подписи известных секторов»)
|
||||
|
||||
Используется шаблон `t(\`stock.sectors.${sector}\`, { defaultValue: sector })` (`frontend/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx:64`) и словарь `stock.sectors` (`frontend/src/shared/i18n/locales/ru.ts:759-770`). Добавляется недостающий ключ `government: "Государственный сектор"` в этот общий словарь. По решению владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) подпись показывается и в каталогах — везде, где выводится сектор через этот словарь (страница акции). Список вариантов фильтра скринера (`frontend/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx:46-62`) не меняется: требования к фильтру нет. Неизвестный код показывается как есть через `defaultValue`. **Это observable-изменение вне Specs:** подпись на странице акции меняется с сырого `government` на «Государственный сектор». Оно одобрено владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)) и фиксируется в Gate Log G3; Specs не дополняются. Проверка — task 2.7. Новые строки вкладки добавляются в `portfolio.concentration.*`.
|
||||
|
||||
## Components and Responsibilities
|
||||
|
||||
| Компонент | Ответственность | Зависит от |
|
||||
| --- | --- | --- |
|
||||
| `TbankClient` (+`getEtf`, `getAssetBrand`, opt-in NOT_FOUND) | Типизированные RPC через общую очередь | gRPC, RateLimiter |
|
||||
| `PositionClassificationService` (portfolio/application) | Решает, нужно ли получать данные (B3); выполняет RPC и дедупликацию (B4); отображает исходы (B5); пробрасывает только `InvalidToken`; возвращает Map `instrumentKey → {sector, issuer}` | `TbankClient`, `PortfolioRepository` |
|
||||
| `PortfolioRepository` (+`readClassifications`, `saveClassification`) | Чтение и upsert строк классификации | Prisma |
|
||||
| `PortfolioService` | Встраивает шаг до publish; нормализует старые Snapshot | Сервис выше |
|
||||
| `tbank-portfolio.mapper` | Записывает `sector`/`issuer` в позицию по ключу `metadataKey(position)` | — |
|
||||
| `TbankHourlyScheduler` | Передаёт `walkStartedAt` | — |
|
||||
| `pages/account/model/concentration.ts` | Разрезы, суммы, доли, «Прочее», N не учтённых, валюты | `@/shared/lib` |
|
||||
| `AccountPage.vm.ts` | State вкладки, разреза и валюты; вызов модели | модель |
|
||||
| `AccountPageLoaded` / `Concentration.view` | Композиция, Alert, диаграмма, список | Mantine, `@mantine/charts` |
|
||||
|
||||
Направление зависимостей не меняется: integrations ← modules/portfolio ← tbank-sync; `pages → entities → shared`.
|
||||
|
||||
## Data / Storage Changes
|
||||
|
||||
Новая Prisma-модель (SQLite, `backend/prisma/schema.prisma`) в стиле `TbankInstrumentLookup`:
|
||||
|
||||
```prisma
|
||||
// Public per-instrument classification; no holdings or credentials.
|
||||
model TbankInstrumentClassification {
|
||||
provider String
|
||||
instrumentKey String // metadataKey(position): JSON [uid|null, figi|null]
|
||||
sector String?
|
||||
sectorFetchedAt DateTime?
|
||||
assetUid String?
|
||||
brandUid String?
|
||||
brandName String?
|
||||
brandFetchedAt DateTime?
|
||||
@@id([provider, instrumentKey])
|
||||
}
|
||||
```
|
||||
|
||||
- Ключ — существующий `metadataKey` (`instrument-metadata.repository.ts:33-38`), без FK на `TbankInstrument`: строка инструмента может ещё не существовать (B1).
|
||||
- Данные публичные и не принадлежат пользователю или подключению, как и `TbankInstrument`. Fence по connection revision не нужен. Публикация Snapshot по-прежнему ограждена (`portfolio.repository.ts:121-131`).
|
||||
- Миграция `…_add_instrument_classification` (`prisma migrate dev --create-only`) делает только `CREATE TABLE`. Прежние таблицы и payload не меняются.
|
||||
- Snapshot (`TbankPortfolio.payload`) получает два поля у позиций. Схема JSON неформальная, поэтому миграция не нужна.
|
||||
|
||||
## Contract Implementation
|
||||
|
||||
| Контракт | Producer → Consumer | Как производится / потребляется | Сверка с target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio`, `positions[].sector`/`issuer` | Backend → frontend | Nest DTO (B6) → `npm run openapi:generate` → artifact передаётся явно → `frontend/src/shared/api/spec/openapi.json` → `npm run openapi:generate` → generated `paths` | Contract Parity на G4: поля, nullable, required и `issuer.{id,name}` сравниваются с таблицей «Семантическая delta» system-analysis; `npm run test:openapi-contract` подтверждает, что генерация совпадает с runtime |
|
||||
| T-Bank `ShareBy`/`BondBy`/`EtfBy`/`GetAssetBy` | T-Bank → backend | Через существующий proto loader; vendor proto не меняется (RPC уже есть: `instruments.proto:24,42,65,92`) | Unit-тесты client на маппинг полей и NOT_FOUND |
|
||||
|
||||
Ошибки HTTP не меняются: `400 INVALID_TBANK_TOKEN` выдаёт существующий путь.
|
||||
|
||||
## Cross-cutting Implementation
|
||||
|
||||
- **Security.** Токен расшифровывается только в attempt (`tokenForAttempt`) и в логи не попадает. Raw gRPC наружу не уходит. Новых прав и секретов нет (security-analysis не выбран, process-record).
|
||||
- **Observability.** Используется существующий `tbank.rpc.failed` на каждый RPC и одно событие `portfolio.classification.failed { code }` на неуспешный шаг. Новых метрик нет (observability не выбран).
|
||||
- **Concurrency.** In-flight Map в singleton (B4). Upsert одной строки из двух attempt идемпотентен, побеждает последняя запись с тем же содержимым. Один процесс backend — по ADR 015 и scheduler (`ponytail` в `tbank-hourly.scheduler.ts:42-43`).
|
||||
- **Freshness.** Классификация не входит в `meta.dependencies` и не меняет `isStale` или `lastSuccessfulAt` (D4). `dataRevision` Snapshot и так растёт при каждой публикации.
|
||||
|
||||
## UI Implementation
|
||||
|
||||
С ux-design описывается только инженерная часть.
|
||||
|
||||
| UI-состояние | Как получается |
|
||||
| --- | --- |
|
||||
| Вкладки нет | `accountId === "all"` либо VM-состояние не `loaded` |
|
||||
| Разрез / валюта / раскрытие «Прочее» | `slice`, `currency` — в VM (F3); раскрытие — локальный `useState` view: сброс при уходе с вкладки допустим, потому что PC «Раскрытие строки «Прочее»» не требует его сохранять |
|
||||
| Пустой разрез | `model.empty`; выбор валюты и сноска остаются (PC «Пустое состояние разреза») |
|
||||
| «Данные неполные» | `partial` из VM (`AccountPage.vm.ts:124`) → Alert вкладки; страничный подавлен (F4) |
|
||||
| Выбор валюты скрыт | `currencies.length <= 1` |
|
||||
| Сноска N | `model.unaccounted > 0` |
|
||||
| Центр и текстовый эквивалент | `model.total`, `model.top5Share` → оверлей и скрытый текст (F5) |
|
||||
|
||||
Visual Package: используется v2 из ux-design (локальные `visuals/*.svg`, хеши — в ux-design «Materials»). В design новых визуальных материалов нет: реализация не добавляет экранов и состояний сверх v2.
|
||||
|
||||
## Developer Verification Strategy
|
||||
|
||||
Проверки задают implementation owners. QA-матрица — в qa-analysis.
|
||||
|
||||
**backend** (команды из `backend/AGENTS.md` и conventions):
|
||||
- `npx prisma validate`, `npx prisma generate`, `npm run typecheck:app`;
|
||||
- проверка миграции на disposable копии БД с существующими Snapshot: старые payload читаются, GET отдаёт `sector: null` и `issuer: null`;
|
||||
- unit-тесты `PositionClassificationService`: четыре исхода по каждому полю отдельно; границы 23:59 и 24:00 от `walkStartedAt`; «не получено → каждый обход»; дедупликация по `asset_uid`; использование сохранённого `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет;
|
||||
- `portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован и вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован с прежними значениями;
|
||||
- `tbank.client.spec.ts`: opt-in NOT_FOUND для `bondBy`/`etfBy`/`getAssetBy`; прежние методы не изменились;
|
||||
- `npm run test:openapi-contract`, `npm run openapi:generate`, `npm run test:e2e` (portfolio HTTP: shape и `400 INVALID_TBANK_TOKEN`);
|
||||
- финально `npm run verify`.
|
||||
|
||||
**frontend** (команды из `frontend/AGENTS.md`):
|
||||
- предоставленный `openapi.json` → `npm run openapi:generate`, `npm run openapi:check`;
|
||||
- vitest для `concentration.ts` по oracle из `analysis/test-cases/`;
|
||||
- `AccountPage.vm.test.tsx`: сохранение разреза и валюты между вкладками, сброс при смене счёта;
|
||||
- `AccountPage.test.tsx`: вкладки нет для `all`; Alert подавлен только на «Концентрации»;
|
||||
- browser-тест раскладки 320 px по образцу `position-layout.browser.test.tsx`;
|
||||
- финально `npm run verify`.
|
||||
|
||||
**specs:** `openspec validate account-top-positions --strict`, `git diff --check`.
|
||||
|
||||
**docs:** `npm run verify` (`docs/CONTRIBUTING.md`).
|
||||
|
||||
## Migration / Rollout / Rollback
|
||||
|
||||
**Порядок выката:**
|
||||
1. Backup БД и ключа шифрования по ADR 015.
|
||||
2. Backend: миграция `CREATE TABLE` и код. Новые поля сразу в DTO (`null` до первого обхода).
|
||||
3. Публикация artifact OpenAPI.
|
||||
4. Frontend PR с новым snapshot и вкладкой.
|
||||
|
||||
Совместимость: старый frontend игнорирует новые поля, поэтому изменение аддитивно. Первый обход после выката запрашивает данные всех позиций: разовый всплеск RPC, B4.
|
||||
|
||||
**Откат:**
|
||||
- **Frontend.** Revert PR; backend продолжает отдавать поля.
|
||||
- **Backend.** Откат на предыдущий bundle (ADR 016). Лишняя таблица не читается.
|
||||
- Фактический запуск: production image стартует `node dist/src/main.js` (`backend/Dockerfile`, `CMD`); приложение миграции не применяет и `_prisma_migrations` не проверяет (`backend/src/prisma/prisma.service.ts`). Миграции применяет отдельный compose-сервис `migration` (`prisma migrate deploy`, `deploy/compose.yaml:32-37`), от которого зависит `backend` (`depends_on … service_completed_successfully`, `:43-45`). Штатный `deploy.sh` запускает его на каждом обновлении (`deploy/deploy.sh:183-186`); ручной image rollback по `deploy/README.md` «Ручной откат и восстановление» делает `up --no-deps backend` и миграцию намеренно не запускает (то же — `docs/architecture/deployment.md` «Откат image»).
|
||||
- Следствие: при ручном откате старый backend стартует на схеме с лишней таблицей, запись о новой миграции в `_prisma_migrations` остаётся. Если вместо ручного отката запустить `deploy.sh` со старым тегом, старый `migrate deploy` увидит применённую миграцию, которой нет в его каталоге. По документации Prisma `migrate deploy` применяет только недостающие локальные миграции и не сверяет историю, а `migrate status` сообщает о ней как о «applied but missing locally»; это не подтверждено на версии проекта и проверяется в task 3.4. Snapshot с полями `sector`/`issuer` читается старым кодом без ошибок: поля лишние, ответ проходит через сериализацию DTO. На G4 это нужно проверить: whitelist сериализации в backend не найден, поэтому старый код может отдавать лишние поля как есть, и это безвредно.
|
||||
- **БД.** `DROP TABLE "TbankInstrumentClassification"` — по желанию, данные восстанавливаются следующим обходом. Restore из backup не нужен.
|
||||
|
||||
Необратимых шагов нет.
|
||||
|
||||
## Solution Assessment
|
||||
|
||||
Обязателен: новое хранение и кеширование данных внешней интеграции.
|
||||
|
||||
| Критерий | A. Новая таблица + шаг до publish (**выбрано**) | B. Расширить `InstrumentMetadataService` / `TbankInstrument.metadata` | C. Хранить в payload Snapshot с переносом из прежнего |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 возраст по полю | Явные столбцы | Поля возраста внутри JSON; меняется `fresh = complete`, а через `metadataDependencies` и `meta` GET (`portfolio.service.ts:122-138`) | Перенос между Snapshot; при новом подключении (новая revision) значения теряются |
|
||||
| D2 четыре исхода | Простой upsert по полю | Drain перезаписывает весь payload и бросает `Incomplete metadata` / `mismatch` (`instrument-metadata.service.ts:141-186`): исходы смешиваются с логикой name/ticker | Реализуемо, но дублируется на каждый счёт |
|
||||
| D3 отказ token | Существующий catch attempt | Работает через собственный attempt metadata, но после publish | Как A |
|
||||
| Связность / риск | Новый сервис около 100 строк, таблица | Высокий: конкурентная очередь producers/observers, общая для GET и каталога | Средний: логика переноса в mapper |
|
||||
| Обратимость | `DROP TABLE` | Трудно отделить от общего кэша | Payload остаётся в истории |
|
||||
| Новая технология | Нет | Нет | Нет |
|
||||
|
||||
Также отвергнуто: `TbankInstrumentSource`. FK на `TbankInstrument` требует сначала создать строку инструмента, а она служит payload общей metadata и каталога: upsert перезаписал бы `metadata` для GET.
|
||||
|
||||
Условие пересмотра A: если появится второй потребитель сектора или бренда (например, каталог), таблицу стоит сделать общим справочником с собственным владельцем, оформив это через ADR.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Риск / компромисс | Влияние | Мера |
|
||||
| --- | --- | --- |
|
||||
| Квоты T-Bank при первом обходе и при постоянных отказах (Q-F3, qa-analysis Gaps) | Шаг Portfolio длиннее; queue full даёт исход «иная ошибка» | Последовательный цикл, дедупликация; ceiling — `WORK_WAIT_BOUND_MS`. Квоты и полнота данных (Q-D1, Q-D2, Q-F3) проверяются на реальном токене в QA до G4 — решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084) |
|
||||
| `fetchedAt` = момент ответа, поэтому фактический период ≈ 25 ч (обход раз в час, ответ позже старта) | Обновление на час позже «суток» | Принято владельцем ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)); соответствует сценариям PVΔ «Обновление сектора и эмитента» буквально |
|
||||
| Шаг до publish удлиняет время до нового Snapshot | Финансовые данные публикуются на секунды–минуты позже | Только когда данные нужно получать; иначе одно чтение из БД |
|
||||
| Отказ token в классификации блокирует Snapshot | Намеренно, D3 | — |
|
||||
| Карточка позиции считает долю через `Number` (`formatShare`), вкладка — точно | В граничных случаях (x,x5) показ может расходиться на 0,1 п.п. | **Принятый компромисс:** карточка не меняется (вне scope), вкладка следует точному расчёту PC «Расчёт долей»/PC «Показ долей»; Specs не меняются |
|
||||
| Ключ `metadataKey` различает «UID» и «UID+FIGI» | Возможны дубли строк одного инструмента | Безвредно: дополнительный RPC раз в сутки |
|
||||
| `government` появится и в каталогах | Подпись вместо сырого кода | Решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); корректно по таблице PC «Русские подписи известных секторов» |
|
||||
|
||||
**Решения владельца ([3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)):** период ≈ 25 ч приемлем (B3); квоты и полнота данных на реальном токене проверяются в QA до G4; «Государственный сектор» — в общем словаре и в каталогах (F6). Открытых findings для Specs и analysis нет: переход UID→FIGI после NOT_FOUND — design-решение B4, расхождение float/точно — принятый компромисс.
|
||||
|
||||
## ADR Candidates
|
||||
|
||||
- **ADR 022 «Хранимая классификация позиций (sector, brand) с суточным обновлением в часовом обходе»**, статус `Accepted` (решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)). Пишется в docs-задаче после реализации, как AS-IS (`docs/adr/022-stored-instrument-classification.md`, по analysis/docs-impact.md «ADR Decision»). Дополняет ADR 019 исключением, которое ввела PVΔ «Свежесть и fallback полного Portfolio Snapshot». Альтернативы — B и C из Solution Assessment. Последствия: первый долговременный справочник T-Bank, условие пересмотра при втором потребителе.
|
||||
- Opt-in NOT_FOUND в `TbankClient` — локальное решение, не ADR.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Process Record
|
||||
|
||||
**Mode:** lab
|
||||
**Class:** complex — межсистемно (backend + frontend + T-Bank), синхронизация внешних данных и финансовые расчёты долей
|
||||
**Issue:** https://git.personal.ksv741.ru/WealthLedger/main/issues/48
|
||||
|
||||
## Change Profile
|
||||
|
||||
| Артефакт | Выбран | Причина |
|
||||
| --- | --- | --- |
|
||||
| research | yes | Trigger: новый продуктовый функционал и вопрос владельца «какой ещё есть критерий?». Нужно сравнить критерии концентрации и формы визуализации в других продуктах. Итог — `analysis/research.md`. |
|
||||
| ux-design | yes | Trigger: новый экран, вкладка «Концентрация» с четырьмя разрезами, выбором валюты, диаграммой и «Прочее». Отдельно нужно решить, как она соотносится с существующей «Структурой портфеля». |
|
||||
| system-analysis | yes | Trigger: меняется контракт Portfolio, в позициях появляются сектор и эмитент. Новые данные берутся из внешней интеграции T-Bank (инструмент, актив и его бренд). |
|
||||
| data-mapping | yes | Trigger: данные T-Bank (sector, asset → brand) нужно преобразовать в данные позиций Portfolio, а коды секторов — в русские подписи. Нужно правило для пустых и неизвестных значений. |
|
||||
| failure-model | yes | Trigger: обогащение позиций идёт через внешнюю интеграцию в часовой синхронизации. Отказ или неполнота данных T-Bank не должны ломать Portfolio Snapshot и его свежесть (Open Decision 4 Proposal). |
|
||||
| nfr | no | Число позиций одного счёта ограничено, вкладка считается по уже загруженному Portfolio. Нагрузку на T-Bank и лимиты запросов рассматривает failure-model; отдельных обязательств по задержке, объёму и хранению нет. |
|
||||
| observability | no | Новых фоновых процессов нет: обогащение идёт внутри существующей синхронизации с её логированием. Недоступность данных видна пользователю как явные группы «не указан»; частичный успех обогащения (Snapshot сохранён, часть данных не получена) фиксируется в failure-model и существующих логах синхронизации без новых метрик и алертов. |
|
||||
| security-analysis | no | Модель доступа и trust boundaries не меняются. Сектор и бренд — публичные справочные данные инструмента, к Portfolio того же User добавляются только они. Новые запросы к T-Bank идут под уже существующим токеном подключения того же User, новых секретов и прав нет. |
|
||||
| test-cases | yes | Trigger: финансовые расчёты (суммы, доли, топ-5, «Прочее», сумма долей = 100 %) и много граничных случаев: одна валюта из нескольких, меньше пяти элементов, равные суммы, нет сектора или бренда, шорт, неоценённые позиции. Для проверки нужны конкретные данные и расчётный oracle. |
|
||||
| docs-impact | yes | Trigger: несколько docs targets — домен Portfolio, страница интеграции T-Bank (новые используемые данные) и глоссарий (Концентрация, Сектор, Эмитент). |
|
||||
|
||||
## Gate Log
|
||||
|
||||
| Gate | Дата | Scope | Validation | Findings | Decision |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate` — ожидаемая ошибка «no deltas» до specs; readiness: plan OK (proposal, process-record, research); HTML-комментариев нет; `git diff --check` OK | G1 review independent-reviewer: 1 blocker, 4 major, 6 minor — закрыты решениями владельца ([issuecomment-3077](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)) и правками; повторный review: ready | approved "вроде все ок" |
|
||||
| G1 | 2026-10-30 | proposal | — | G2 review B1: правило валют вкладки противоречит proposal | invalidated: решение владельца по B1 ([issuecomment-3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)) меняет правило валют в proposal |
|
||||
| G1 | 2026-10-30 | proposal, change-profile | `openspec validate --strict` valid; readiness: plan OK; proposal обновлён по 3079 (свой список валют вкладки) | G2 re-review independent-reviewer: B1 закрыт в proposal | approved "Да, G1 ок" ([issuecomment-3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)) |
|
||||
| G2 | 2026-10-30 | specs | `openspec validate --strict` valid; `git diff --check` OK; readiness: plan OK; self-check по spec-writing.md | G2 review qa-engineer + independent-reviewer: 1 blocker (B1, валюты), 4 major, ~12 minor — закрыты решениями владельца (3079, 3080) и правками; финальный re-review: ready | approved "да, идем дальше" |
|
||||
| G2 | 2026-10-30 | specs | — | system-analysis/ux-design: эмитент у фондов, обновление metadata, имя эмитента при расхождении, поведение вкладки (сохранение выбора в пределах страницы, скрытый Select, метрика ≤5, partial) | invalidated: решения владельца ([issuecomment-3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)) меняют Specs |
|
||||
| G2 | 2026-10-30 | specs, system-analysis, data-mapping, failure-model | `openspec@1.13.1 validate --strict` valid; `validate --specs` 33/33; `git diff --check` OK; readiness: plan OK; Coverage 32/32 | G2 rev2/rev3 review independent-reviewer + qa-engineer: 1 blocker (B1 — противоречие canonical «Свежесть и fallback»), 3 major, ~8 minor — закрыты решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)) и правками; финальный re-review: ready | approved "ок, давай дальше" |
|
||||
| G3 | 2026-10-31 | design, ux-design, qa-analysis, test-cases, docs-impact, tasks | `openspec@1.13.1 validate --strict` valid; `git diff --check` OK; readiness: apply OK; Visual Package v2 SHA-256 сверены reviewer; Coverage 32/32 | G3 review independent-reviewer + frontend-engineer: 4 major, 4 minor, Q2 — закрыты; backend-engineer не выполнен (квота API), backend-утверждения design сверены основным агентом с кодом; повторный review: ready. Включает изменение вне Specs: подпись «Государственный сектор» на странице акции и в каталогах (3084) | approved "да, все окей, идем дальше" |
|
||||
| G4 | 2026-11-02 | archive | Код и docs в `develop`: backend 17432df, frontend 70a2c33, docs 37430da; `openspec@1.13.1 validate --strict` valid | Independent Review, QA и проверка отката не выполнялись | archived without verification: решение владельца в сессии specs#74 (descope 3.1–3.4, 5.1) |
|
||||
|
||||
Примечание к G2 (2026-10-30): GIVEN «сектор и бренд актива позиции ещё ни разу не получены» в Scenario «Недоступность сектора и эмитента не нарушает Snapshot» (finding N1) и замена строки design-обязательства в failure-model (N2) внесены основным агентом до записи approved G2 и входят в утверждённую версию.
|
||||
|
||||
## Descoped Work
|
||||
|
||||
| Task | Причина | Gate |
|
||||
| --- | --- | --- |
|
||||
| 3.1 Independent Review | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
|
||||
| 3.2 QA test-cases | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
|
||||
| 3.3 QA exploratory | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
|
||||
| 3.4 Откат backend | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
|
||||
| 5.1 Запись G4 с evidence | Владелец решил архивировать без G4: код и docs уже в `develop` и релизах | G4 |
|
||||
|
||||
## Lab Journal
|
||||
|
||||
| Стадия | Роль / сабагент | Уникальные находки | Решения | Риски найдены / исключены | Проблемы передачи | Skills | Стоимость | Переносимо |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| research | product-researcher | В Portfolio DTO нет сектора и эмитента, хотя T-Bank их отдаёт. Доля топ-5 понятнее HHI. Общий мультивалютный рейтинг без конвертации некорректен. «Структура» дублировала бы существующий виджет. | MVP: акции, облигации, секторы; позже: страна, maturity, riskLevel, P&L, HHI; не делать: кредитный рейтинг, конвертацию, treemap | Найден: полнота sector неизвестна. Исключено: смешение валют | Нет | — | 1 вызов | Шаблон «must / later / no» для выбора критериев |
|
||||
| proposal | product-analyst + основной агент | Эмитент требует отдельной идентичности; основной агент нашёл в proto T-Bank `AssetFull.brand` {uid, name, company, sector} (краткий `Asset` бренда не содержит). 8 Open Decisions, 3 из них меняют scope | Владелец: «Эмитенты» в MVP (отклонение от research: Later → MVP), эмитент = бренд T-Bank, фонды не входят, госбумаги — по сектору T-Bank, режим lab | Найден: «Эмитенты» зависят от полноты бренда. Исключено: группировка по названию | Валидация change ждёт delta specs (ожидаемо) | — | 1 вызов + 2 вопроса владельцу | Решать scope-вопросы до G1 через короткий опрос |
|
||||
| G1 review | independent reviewer + product-analyst | Источник бренда указан неточно (`Asset` вместо `AssetFull.brand`); «Секторы» не задавали набор типов; раскрытие «Прочее» было Open Decision; не зафиксировано отклонение от research | Владелец ([Issue #48, комментарий](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)): «Прочее» раскрывается; без бренда — fallback «Эмитент не указан» без нового G1; «Секторы» только STOCK/BOND/FUND; общий топ-5 вне scope | Найден: несоответствие источника и scope. Исключено: зависимость «Эмитентов» от полноты бренда | Proposal правился после review; Gate Log не заполнялся | — | 1 вызов | Сверять ссылки на proto с исходником до G1 |
|
||||
| specs | product-analyst | Остальные нормы вкладки не требуют новых источников; в canonical нет перечня вкладок страницы счёта и правил выбора валюты «Структуры портфеля» (они только в коде и docs) — правила валюты, округления, равных сумм определены в Specs и вынесены на подтверждение G2. Эмитент и сектор — новые поля позиции Portfolio | 2 дельты: новая `portfolio-concentration` (23 Requirement), `portfolio-view` MODIFIED «Нормализованный состав Portfolio». Решения владельца ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)) внесены | Найден: расхождение «Структуры портфеля» (деньги в знаменателе и в валютах) и вкладки (без денег) — различие зафиксировано явно | Нет | — | 1 вызов | Сверять правила «как в существующем виджете» с canonical до написания, а не с кодом |
|
||||
| G2 review | qa-engineer + independent-reviewer + product-analyst | Валюты вкладки не были определены независимо от «Структуры»; «Прочее» при шести элементах скрывало одну строку; метод наибольшего остатка противоречил показу долей в карточках; подписи секторов без источника; не было граничных сценариев 5/6 элементов и противоречивый GIVEN | Владелец ([3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)): свой список валют вкладки; округление как в карточках, «< 0,1 %»; «Прочее» от семи элементов. Автор: список валют включает отрицательные и нулевые позиции STOCK/BOND/FUND; `null` считается в сноске при любой валюте | Найден: расхождение формата долей между спеками и кодом. Исключено: инвариант «100,0 %» на показанных значениях | Gate Log ведёт основной агент (G1 инвалидирован) | — | 1 вызов | Сверять формат чисел с кодом существующего виджета до написания норм |
|
||||
| specs rev2 | product-analyst | G2 инвалидирован решениями [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081): issuer у фондов в данных; обновление sector/brand раз в сутки с последним известным значением; отказ token — как существующая недействительность; trim пробелов; имя эмитента по наибольшей позиции; сохранение выбора в пределах страницы; метрика топ-5 при ≥6 элементах; центр кольца; «Данные неполные» | `portfolio-concentration` 28 Requirement (+5: вкладка в состояниях страницы, название эмитента при разных названиях, центр диаграммы, строки без переходов, предупреждение о неполных данных; «Суммарная доля топ-5» и «Начальное состояние» переписаны); `portfolio-view` MODIFIED + 3 ADDED (срок обновления, последнее известное, ошибки получения) | Найден: «Данные неполные» привязано к `meta` по `tbank-data-sync`, не к неоценённым позициям. Исключено: конфликт metadata/invalid token (отсылка к существующему поведению) | Gate Log ведёт основной агент | — | 1 вызов | Сверять производные analysis-артефакты с Delta Specs до G2 |
|
||||
| specs rev3 | product-analyst | G2 re-review: хранение sector/brand противоречило «Свежесть и fallback» (только онлайн, без долговременного справочника) и «metadata не определяет успешность»; граница 24 ч была сформулирована как «24 ч + интервал»; «не найдено», пустой ответ и ответ о другом активе не были определены | [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082): исключение для sector/brand; «не найдено» = «значения нет»; равные наибольшие позиции эмитента — по алфавиту; предупреждение о неполных данных только на вкладке | `portfolio-view`: MODIFIED «Свежесть и fallback» (полная копия + 2 исключения и 2 сценария), ADDED «Обновление сектора и эмитента» и «Результат получения сектора и эмитента» (таблица исходов), без дублей; `portfolio-concentration`: название эмитента по алфавиту, предупреждение без дубля | Найден: страничного сообщения о неполноте в canonical «Состояния и ошибки Portfolio на клиенте» нет (требует лишь не выдавать неполный итог за окончательный), поэтому MODIFIED для него не нужен. Исключено: дублирование норм об ошибках | Gate Log ведёт основной агент | — | 1 вызов | Если новая норма хранит данные, сверять с общими «только онлайн» правилами до написания |
|
||||
| design ∥ qa-analysis | solution-architect, qa-engineer | Общий metadata-кэш не подходит под 24 ч и 4 исхода, нужна отдельная таблица классификации; шаг классификации должен идти до publish, иначе отказ token не инвалидирует подключение; DonutChart 9.7.3 хватает без прямого recharts | Таблица `TbankInstrumentClassification`, opt-in NOT_FOUND только для новых вызовов, состояние вкладки поднято в page VM; владелец: ~25 ч, квоты в QA до G4, «Государственный сектор» в общем словаре (3084) | Найдено: изменение страницы акции вне Specs (одобрено 3084); откат образа без миграции | Субагенты не видели полный текст review, findings пересылались вручную | — | 2 вызова + 3 доработки | Сверять design-ссылки на код до G3 |
|
||||
| test-cases + docs-impact + tasks | qa-engineer, documentation-engineer, solution-architect | 21 из 30 зарезервированных TC ничего не добавляли сверх Scenario и выброшены; «эмитент» конфликтует с термином docs | 15 TC + TC-CONC-020; глоссарий с оговоркой, ADR 022 (3083); 28 tasks по 4 repo | Найдено: 2 неверных oracle (TC-CONC-007, TC-META-003) | — | — | 3 вызова | Пересчитывать oracle независимым ревьюером |
|
||||
| G3 review | independent-reviewer, frontend-engineer; backend-engineer — сбой по квоте API (3 попытки) | 4 major, 4 minor, Q2 (откат без миграции); frontend API DonutChart подтверждён | Все findings закрыты; backend-утверждения design (до publish, InvalidToken catch, NOT_FOUND в `call()`, `WORK_WAIT_BOUND_MS`) основной агент сверил с кодом вручную | Остаётся: backend-ревью субагентом не выполнено | Квота API | — | 5 вызовов | Ревью backend повторить на G4 |
|
||||
@@ -0,0 +1,152 @@
|
||||
# Proposal
|
||||
|
||||
Issue: WealthLedger/main#48
|
||||
|
||||
## Why
|
||||
|
||||
Страница счёта показывает состав Portfolio списком позиций и распределением по классам активов. Ответа на вопрос «где сконцентрированы деньги» нет: чтобы понять, какие бумаги, отрасли и компании занимают основную часть счёта, владельцу приходится вручную сравнивать карточки позиций. Распределение по классам активов этого не даёт: оно не показывает ни крупнейшие позиции, ни отраслевой или эмитентский перекос.
|
||||
|
||||
## What Changes
|
||||
|
||||
- На странице счёта рядом с вкладкой «Позиции» появляется вкладка «Концентрация».
|
||||
- Вкладка содержит четыре разреза текущего состава Portfolio выбранного счёта: «Акции», «Облигации», «Секторы», «Эмитенты».
|
||||
- В каждом разрезе показаны пять крупнейших элементов по текущей денежной оценке, каждый с суммой и долей, а при семи и более элементах все остальные объединены в одну строку «Прочее» (при шести или меньше элементах каждый показан своей строкой). По нажатию на «Прочее» раскрывается полный перечень остальных элементов, каждый с суммой и долей; пять первых строк при этом не меняются. Рядом с перечнем показана диаграмма распределения, согласованная с перечнем, и суммарная доля пяти первых элементов как показатель концентрации.
|
||||
- «Акции» и «Облигации» ранжируют позиции типов `STOCK` и `BOND` соответственно. Фонды в эти два разреза не входят.
|
||||
- «Секторы» агрегируют только позиции типов `STOCK`, `BOND` и `FUND`. Позиции типов `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` и `OTHER` в разрез не входят. Позиции перечисленных трёх типов без сектора попадают в явную группу «Сектор не указан».
|
||||
- «Секторы» используют сектор инструмента в том виде, как его определяет T-Bank, с русской подписью; государственные облигации определяются значением сектора T-Bank `government` с подписью «Государственный сектор», других собственных классификаций WealthLedger не вводит.
|
||||
- «Эмитенты» объединяют акции и облигации одной компании в один элемент. Эмитент — бренд актива по данным T-Bank; название эмитента — название бренда (при разных названиях одного бренда — название позиции с наибольшей стоимостью). Эмитент заполняется в данных Portfolio и у фондов, а разрез «Эмитенты» фонды отбрасывает. Позиции без бренда попадают в явную группу «Эмитент не указан» с видимой долей; если бренда нет у многих позиций, разрез всё равно остаётся в MVP, а объём scope не меняется. Фонды в разрез не входят: их эмитент — управляющая компания, а не компании внутри фонда.
|
||||
- Значения показываются в одной валюте за раз. У вкладки свой список валют и свой выбор, независимые от «Структуры портфеля»: в списке валюты позиций акций, облигаций и фондов; по умолчанию выбрана первая по ISO-коду. При одной валюте выбор не показывается. Конвертации нет, общего итога по нескольким валютам нет. Выбранные разрез и валюта сохраняются, пока User остаётся на странице счёта, и сбрасываются при новом заходе и перезагрузке. Доли показываются в формате карточек позиций (обычное округление до одного знака, «< 0,1 %» для очень малых).
|
||||
- Для разрезов «Секторы» и «Эмитенты» в данных позиций Portfolio появляются сектор и признак эмитента. Это расширение ответа Portfolio новыми необязательными данными; существующие данные позиций и их смысл не меняются. Изменение не является BREAKING.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Вкладка «Концентрация» на странице одного счёта.
|
||||
- Разрезы «Акции», «Облигации», «Секторы», «Эмитенты» с топ-5, «Прочее», суммой и долей по элементам, диаграммой распределения и суммарной долей топ-5.
|
||||
- Раскрытие «Прочее» по нажатию с полным перечнем остальных элементов.
|
||||
- Явные группы «Сектор не указан» и «Эмитент не указан» с видимой долей.
|
||||
- Выбор одной валюты, пересчёт всех разрезов вкладки под выбранную валюту.
|
||||
- Состояния вкладки при отсутствии позиций в выбранной валюте, при отсутствии нужных данных (сектор, эмитент) и при ошибке или устаревании Portfolio — в рамках уже действующего поведения Portfolio.
|
||||
- Расширение данных позиций Portfolio сектором и признаком эмитента (аддитивно).
|
||||
- Обновление AS-IS документации домена Portfolio, интеграции T-Bank и глоссария (термины «Концентрация», «Сектор», «Эмитент»).
|
||||
|
||||
### Out of Scope / Non-Goals
|
||||
|
||||
- Общий топ-5 по всем типам позиций вместе (исходная формулировка Issue): рейтинги строятся только внутри разрезов «Акции», «Облигации», «Секторы», «Эмитенты».
|
||||
- Страна риска и региональное распределение.
|
||||
- Сроки погашения облигаций.
|
||||
- Лидеры роста и падения, доход по позициям и вклад выплат в доход.
|
||||
- Показатель `riskLevel` T-Bank и кредитный рейтинг агентств.
|
||||
- Индекс концентрации (HHI) и эффективное число позиций.
|
||||
- Конвертация валют, единый рейтинг или единая диаграмма по нескольким валютам.
|
||||
- Замена, изменение или повтор существующей «Структуры портфеля» и вкладки «Позиции».
|
||||
- Режим «Все счета»: вкладка относится только к странице одного счёта.
|
||||
- Изменение порядка, состава и свежести Portfolio Snapshot.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `specs/portfolio/portfolio-concentration/spec.md`: вкладка «Концентрация» — разрезы, ранжирование топ-5, «Прочее», доли и суммарная доля топ-5, выбор валюты, правила учёта позиций, состояния.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `specs/portfolio/portfolio-view/spec.md`: нормализованный состав Portfolio расширяется сектором и эмитентом позиции (требование «Нормализованный состав Portfolio»); добавляются требования о сроке обновления, последнем известном значении и ошибках получения сектора и эмитента. Требование «Точные значения и валютные агрегаты» не меняется; новая capability `portfolio-concentration` на него ссылается.
|
||||
|
||||
## Impact
|
||||
|
||||
- Владелец счёта получает новый экран анализа; существующие экраны и их поведение не меняются.
|
||||
- Затрагиваются backend (обогащение данных Portfolio), frontend (новая вкладка и её разрезы), specs и AS-IS документация.
|
||||
- Ответ Portfolio расширяется аддитивно: клиенты, не знающие новых данных, продолжают работать без изменений. Публичные API-артефакты потребуют обновления.
|
||||
- Часовая синхронизация с T-Bank дополнительно получает данные об инструментах и брендах для позиций; это может увеличить число обращений к T-Bank и время синхронизации. Оценка нагрузки и способ получения — в Design.
|
||||
- Сбой или неполнота этих данных не должны ухудшать успешность и свежесть Portfolio Snapshot (решено, см. Open Decisions); без новых данных позиции показываются в явных группах «Сектор не указан» и «Эмитент не указан».
|
||||
- Сектор и бренд актива обновляются не чаще раза в сутки (значения могут отставать от T-Bank не более чем на сутки плюс интервал часового обхода); при неуспешном обновлении сохраняется последнее известное значение. Пробелы по краям обрезаются, значение из одних пробелов считается отсутствующим. Отказ token при получении сектора или бренда обрабатывается как существующая недействительность подключения T-Bank.
|
||||
- На вкладке: в центре диаграммы «Топ-5 · N %» (при шести и более элементах) и сумма разреза, без логотипов и переходов в строках, предупреждение «Данные неполные» при неполном Portfolio; вкладки нет при загрузке, ошибке и пустом Portfolio.
|
||||
- Существующие данные Portfolio, порядок позиций и итоги по валютам не меняются.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- На странице счёта вкладка «Концентрация» доступна рядом с «Позиции»; вкладка «Позиции» и «Структура портфеля» выглядят и работают как раньше.
|
||||
- В каждом из четырёх разрезов при выбранной валюте при семи и более элементах видны пять строк и «Прочее», при шести или меньше — отдельные строки без «Прочее»; точная сумма строк равна общей сумме разреза в этой валюте.
|
||||
- Доля каждого элемента и суммарная доля первых пяти считаются от одной и той же базы, а диаграмма и перечень показывают одни и те же элементы и значения; доли показаны в формате карточек позиций и могут в сумме отличаться от 100 % из-за округления.
|
||||
- Суммы точны и не конвертируются: при выборе валюты в разрезах нет позиций других валют.
|
||||
- Нажатие на «Прочее» раскрывает все остальные элементы разреза с суммой и долей каждого; состав и порядок топ-5 не меняются.
|
||||
- В разрезе «Секторы» присутствуют только позиции `STOCK`, `BOND`, `FUND`; позиции других типов в нём отсутствуют. Позиция этих типов без сектора учитывается в группе «Сектор не указан».
|
||||
- Акция и облигация одного эмитента в разрезе «Эмитенты» показываются одним элементом с общей суммой; фонд в этом разрезе отсутствует.
|
||||
- Позиция без бренда учитывается в группе «Эмитент не указан», и её доля видна пользователю.
|
||||
- Общего топ-5 по всем типам позиций на вкладке нет.
|
||||
- Позиция без вычислимой текущей стоимости не влияет на суммы и доли.
|
||||
|
||||
## Evidence
|
||||
|
||||
Исследование: `analysis/research.md` (ссылки в формате его источников; здесь не копируются).
|
||||
|
||||
- [F] Потребность: показать крупнейшие акции, облигации и секторы по денежной оценке с переключением критерия и диаграммой распределения. Источник: запрос Change Owner, WealthLedger/main#48; `analysis/research.md`, Problem Statement.
|
||||
- [F] Страница счёта уже содержит распределение по классам активов с выбором одной валюты и вкладку «Позиции» с разделением «Акции / Облигации / Прочее». Источник: `analysis/research.md`, Problem Statement.
|
||||
- [F] Каноническая `portfolio-view` фиксирует отсутствие валютной конвертации и отдельные итоги по каждой валюте; позиция без вычислимой текущей стоимости остаётся в ответе, но не входит в агрегаты; в Portfolio входят только позиции с ненулевым количеством. Источник: `specs/portfolio/portfolio-view/spec.md`, требования «Нормализованный состав Portfolio», «Точные значения и валютные агрегаты».
|
||||
- [F] Карточки позиций показывают долю позиции среди позиций той же валюты. Источник: `specs/portfolio/portfolio-view/spec.md`, требование «Представление карточек позиций на клиенте».
|
||||
- [F] Текущие данные позиции Portfolio не содержат сектора и эмитента. Источник: `analysis/research.md` [L4].
|
||||
- [F] Сектор есть у инструментов Share, Bond и Etf в интеграционном описании T-Bank. Источник: `backend/src/integrations/tbank/proto/contracts/instruments.proto` (Share, Bond, Etf, поле `sector`).
|
||||
- [F] Бренд с компанией и сектором (`Brand`, :1220-1229) доступен в полном описании актива `AssetFull.brand` (:1051-1067). Краткое описание актива `Asset` бренда не содержит, а `BrandData` внутри инструментов хранит только данные логотипа. Ссылка инструмента на актив (`asset_uid`) есть у Share (:776), Bond (:543) и Etf (:657). Источник: тот же `instruments.proto`; `analysis/research.md` [L9].
|
||||
- [F] Разрез «Эмитенты» перенесён из Later research в MVP решением Change Owner (комментарий в Issue #48, issuecomment-3077): условие research «после подтверждения issuer identity» снято наличием `AssetFull.brand`.
|
||||
- [F] Группировка по эмитенту акций и облигаций описана в публичной справке Т-Инвестиций; секторное распределение подтверждено несколькими продуктами. Источник: `analysis/research.md`, Evidence & Sources [E1, E3, E6, E7].
|
||||
- [F] Внешние продукты — источник кандидатов, но не доказательство спроса. Источник: `analysis/research.md`, Target Audience.
|
||||
- [H] Бренд актива T-Bank пригоден как идентичность эмитента; полнота бренда на позициях владельца неизвестна. Группировка по совпадающему названию позиций ненадёжна и не используется. Fallback согласован владельцем: нет бренда → «Эмитент не указан»; полнота проверяется в data-mapping.
|
||||
- [H] У позиций владельца сектор заполнен в большинстве случаев; доля стоимости без сектора и состав значений (включая «государственные ценные бумаги») неизвестны. Проверяется до Specs на данных Portfolio владельца.
|
||||
- [H] Сектор у фондов в данных T-Bank присутствует не всегда. Проверяется вместе с предыдущей гипотезой.
|
||||
- [H] Отрицательная текущая стоимость (короткая позиция) возможна в Portfolio. Спецификация допускает отрицательные значения, но наличие таких позиций у владельца не подтверждено.
|
||||
- [H] Недоступность сектора или эмитента не должна ломать загрузку Portfolio — по аналогии с логотипом, который является только presentation metadata. Не подтверждено.
|
||||
|
||||
## Assumptions / Dependencies
|
||||
|
||||
- Базой для вкладки служит сохранённый Portfolio Snapshot со своей свежестью и статусами; вкладка не запускает собственных обновлений.
|
||||
- У вкладки собственный выбор валюты, независимый от «Структуры портфеля» (решение владельца, [комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)).
|
||||
- «Структура портфеля» остаётся отдельным блоком; её замена вкладкой «Концентрация» не планируется.
|
||||
- Данные о секторе и бренде берутся из T-Bank; способ получения и хранения — предмет Design.
|
||||
- Пять элементов — фиксированный размер топа для всех разрезов; настройка размера не нужна.
|
||||
- Элементы разрезов «Акции» и «Облигации» — отдельные позиции; одна и та же бумага на одном счёте с одинаковым FIGI не объединяется (действующее правило `portfolio-view`).
|
||||
|
||||
## Open Decisions
|
||||
|
||||
Все вопросы решает Change Owner. Ни один из них не меняет набор разрезов и не требует нового G1.
|
||||
|
||||
**Решены Change Owner до G1** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077); решения внесены в What Changes):
|
||||
|
||||
- Эмитент — бренд актива T-Bank; позиции без бренда показываются в группе «Эмитент не указан» с видимой долей. Если бренда нет у многих позиций, разрез остаётся, scope не меняется, новый G1 не нужен.
|
||||
- Фонды в разрез «Эмитенты» не входят.
|
||||
- Государственные облигации в «Секторах» — по сектору, как его определяет T-Bank, без собственной классификации.
|
||||
- «Секторы» включают только `STOCK`, `BOND`, `FUND`; остальные типы позиций в разрез не входят.
|
||||
- «Прочее» раскрывается по нажатию с полным перечнем остальных элементов; состав топ-5 не меняется.
|
||||
- Общий топ-5 по всем типам из исходного Issue — вне scope.
|
||||
- Отклонение от research: там «Эмитенты» были в Later «после подтверждения issuer identity». Условие снято, так как бренд актива T-Bank (`AssetFull.brand`) даёт стабильную идентичность компании; полнота бренда на позициях владельца остаётся гипотезой, её покрывает fallback «Эмитент не указан».
|
||||
|
||||
**Решены Change Owner до G2** (дата 2026-10-30; дословные формулировки — [комментарий в Issue #48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078); решения внесены в Delta Specs):
|
||||
|
||||
- Знаменатель доли — сумма учитываемых позиций разреза в выбранной валюте; денежные остатки в него не входят.
|
||||
- Позиции с отрицательной стоимостью и без оценки исключаются из ранжирования, долей и диаграммы; сноска показывает, сколько позиций не учтено.
|
||||
- Группы «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными элементами и заметно обозначены.
|
||||
- Отсутствие сектора или эмитента не влияет на успешность и свежесть Portfolio Snapshot, как и логотип; у позиций в таких случаях пустые значения.
|
||||
|
||||
**Решены Change Owner после G2-review** ([комментарий 3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079); внесены в Delta Specs):
|
||||
|
||||
- Вкладка имеет собственный список валют и выбор, независимые от «Структуры портфеля».
|
||||
- Доли показываются с округлением карточек позиций; точные доли в сумме дают 100 %.
|
||||
- «Прочее» появляется только при семи и более элементах.
|
||||
|
||||
**Подтверждены Change Owner** ([комментарий 3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080); внесены в Delta Specs):
|
||||
|
||||
- Список валют вкладки: валюты ненулевых `currentValue` позиций акций, облигаций и фондов, включая отрицательные; валюта только с нулевой стоимостью в список не входит.
|
||||
- В сноске «Не учтено позиций» позиции без оценки считаются при любой выбранной валюте; при пропаже выбранной валюты берётся первая из списка; равные суммы упорядочиваются по названию.
|
||||
- Название эмитента — название бренда T-Bank.
|
||||
- Подпись сектора `government` — «Государственный сектор» (добавление WealthLedger к подписям скринера).
|
||||
|
||||
**Подтверждены Change Owner** ([комментарий 3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081); внесены в Delta Specs):
|
||||
|
||||
- Эмитент заполняется у фондов в данных Portfolio, разрез «Эмитенты» их отбрасывает.
|
||||
- Отказ token при получении сектора или бренда — как существующая недействительность подключения.
|
||||
- Сектор и бренд обновляются раз в сутки; при ошибке сохраняется последнее известное значение.
|
||||
- Пробелы обрезаются; при разных названиях одного эмитента берётся название позиции с наибольшей стоимостью.
|
||||
- Разрез и валюта сохраняются в пределах страницы счёта; выбор валюты скрыт при одной валюте; метрика «доля топ-5» скрыта при пяти и менее элементах; при неполных данных — предупреждение «Данные неполные»; в центре диаграммы «Топ-5 · N %» и сумма; в «Эмитентах» без логотипов и переходов.
|
||||
|
||||
Открытых решений нет.
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
# portfolio-concentration Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Позволяет авторизованному User увидеть на странице одного Brokerage Account, какие акции, облигации, секторы и эмитенты занимают наибольшую часть текущей оценки Portfolio в выбранной валюте, без валютной конвертации.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Вкладка «Концентрация» страницы счёта
|
||||
Система SHALL показывать на странице одного Brokerage Account вкладку «Концентрация» рядом с вкладкой «Позиции». Система SHALL NOT показывать вкладку в режиме «Все счета». Добавление вкладки SHALL NOT менять вкладку, открываемую по умолчанию, и содержание остальных вкладок.
|
||||
|
||||
#### Scenario: Вкладка на странице одного счёта
|
||||
- **WHEN** User открывает страницу доступного Brokerage Account
|
||||
- **THEN** рядом с вкладкой «Позиции» доступна вкладка «Концентрация», а по умолчанию открыта прежняя вкладка
|
||||
|
||||
#### Scenario: Режим «Все счета»
|
||||
- **WHEN** User открывает общий портфель в режиме «Все счета»
|
||||
- **THEN** вкладки «Концентрация» нет
|
||||
|
||||
### Requirement: Вкладка в состояниях страницы
|
||||
Система SHALL NOT показывать вкладку «Концентрация», пока Portfolio загружается, при его ошибке или недоступности и при штатном пустом Portfolio по требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`. Вкладка SHALL показываться при Portfolio хотя бы с одной позицией или денежным остатком.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||
|
||||
#### Scenario: Загрузка и ошибка
|
||||
- **WHEN** Portfolio загружается либо получение завершилось ошибкой
|
||||
- **THEN** вкладки «Концентрация» нет
|
||||
|
||||
#### Scenario: Штатный пустой Portfolio
|
||||
- **WHEN** у счёта нет позиций и денежных остатков
|
||||
- **THEN** вкладки «Концентрация» нет
|
||||
|
||||
#### Scenario: Только денежные остатки
|
||||
- **WHEN** у счёта есть Cash Balance и нет позиций
|
||||
- **THEN** вкладка «Концентрация» есть, разрез показывает пустое состояние
|
||||
|
||||
### Requirement: Переключение разрезов
|
||||
Вкладка «Концентрация» SHALL предоставлять разрезы «Акции», «Облигации», «Секторы», «Эмитенты» в этом порядке и показывать одновременно один из них. При новом заходе на страницу счёта SHALL быть открыт разрез «Акции» по требованию «Начальное состояние вкладки». Разрез — набор элементов, на которые делится оценка позиций; элемент — единица ранжирования разреза по требованию «Состав разрезов».
|
||||
|
||||
#### Scenario: Открытие вкладки
|
||||
- **WHEN** User при новом заходе на страницу счёта открывает вкладку «Концентрация»
|
||||
- **THEN** показан только разрез «Акции» и доступны переключатели «Акции», «Облигации», «Секторы», «Эмитенты»
|
||||
|
||||
#### Scenario: Переключение разреза
|
||||
- **GIVEN** открыт разрез «Акции» с выбранной валютой USD
|
||||
- **WHEN** User переключает разрез на «Секторы»
|
||||
- **THEN** показан только разрез «Секторы» в валюте USD
|
||||
|
||||
### Requirement: Начальное состояние вкладки
|
||||
Система SHALL открывать вкладку «Концентрация» при новом заходе на страницу счёта и после перезагрузки с разрезом «Акции» и первой валютой списка. Новый заход — открытие страницы после ухода с неё либо переключение счёта. Пока User остаётся на странице, включая переключение её вкладок, разрез и валюта SHALL сохраняться.
|
||||
|
||||
**Источник (ненормативно):** [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||
|
||||
#### Scenario: Возврат с другой вкладки страницы
|
||||
- **GIVEN** на вкладке выбраны разрез «Секторы» и валюта USD
|
||||
- **WHEN** User переключается на вкладку «Позиции» и возвращается на «Концентрация»
|
||||
- **THEN** выбраны разрез «Секторы» и валюта USD
|
||||
|
||||
#### Scenario: Повторный заход
|
||||
- **GIVEN** User выбрал разрез «Секторы» и валюту USD, затем покинул страницу счёта
|
||||
- **WHEN** User снова открывает страницу счёта и вкладку «Концентрация»
|
||||
- **THEN** открыт разрез «Акции» с первой валютой списка
|
||||
|
||||
#### Scenario: Перезагрузка
|
||||
- **GIVEN** на вкладке выбраны разрез «Эмитенты» и валюта USD
|
||||
- **WHEN** User перезагружает страницу
|
||||
- **THEN** после открытия вкладки показаны разрез «Акции» и первая валюта списка
|
||||
|
||||
### Requirement: Состав разрезов
|
||||
Элементы разреза SHALL формироваться из позиций типов по таблице; другие типы, включая `CURRENCY`, SHALL NOT входить во вкладку.
|
||||
|
||||
| Разрез | Типы | Элемент |
|
||||
| --- | --- | --- |
|
||||
| Акции | `STOCK` | Позиция |
|
||||
| Облигации | `BOND` | Позиция |
|
||||
| Секторы | `STOCK`, `BOND`, `FUND` | Позиции с одним `sector` |
|
||||
| Эмитенты | `STOCK`, `BOND` | Позиции с одним `issuer.id` |
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
|
||||
|
||||
#### Scenario: Сектор объединяет акции, облигации и фонды
|
||||
- **GIVEN** в RUB есть акция сектора `energy` на 400, облигация `energy` на 100, фонд `financial` на 300 и позиция `CURRENCY` на 50
|
||||
- **WHEN** User открывает разрез «Секторы» в RUB
|
||||
- **THEN** элементы — «Энергетика» 500 ₽ и «Финансы» 300 ₽, позиции `CURRENCY` в разрезе нет
|
||||
|
||||
#### Scenario: Акция и облигация одного эмитента
|
||||
- **GIVEN** в RUB есть акция с `issuer.id` `brand-1` («Сбер») на 300, облигация `brand-1` на 200 и фонд `brand-1` на 100
|
||||
- **WHEN** User открывает разрез «Эмитенты» в RUB
|
||||
- **THEN** есть один элемент «Сбер» на 500 ₽, фонд в него не включён
|
||||
|
||||
#### Scenario: Фонд вне «Акций»
|
||||
- **GIVEN** в RUB есть фонд на 100 и акция на 200
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показана только акция на 200 ₽ с долей 100 %
|
||||
|
||||
### Requirement: Названия элементов
|
||||
Название элемента в «Акциях» и «Облигациях» SHALL быть отображаемым идентификатором позиции по требованию «Нормализованный состав Portfolio» из `portfolio-view` (name, затем ticker, затем FIGI), в «Секторах» — подписью по требованию «Подписи секторов», в «Эмитентах» — `issuer.name`, то есть названием бренда актива T-Bank. Позиции с разными `issuer.id` SHALL NOT объединяться при одинаковом `issuer.name`.
|
||||
|
||||
#### Scenario: Идентификатор позиции без имени
|
||||
- **GIVEN** акция не имеет name, но имеет ticker `SBER`
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** элемент назван `SBER`
|
||||
|
||||
#### Scenario: Название эмитента — название бренда
|
||||
- **GIVEN** акция и облигация имеют `issuer.id` `brand-1` и `issuer.name` «Сбер»
|
||||
- **WHEN** User открывает разрез «Эмитенты»
|
||||
- **THEN** один элемент назван «Сбер»
|
||||
|
||||
#### Scenario: Разные эмитенты с одинаковым названием
|
||||
- **GIVEN** две акции имеют `issuer.name` «Альфа» и разные `issuer.id`
|
||||
- **WHEN** User открывает разрез «Эмитенты»
|
||||
- **THEN** показаны два отдельных элемента «Альфа»
|
||||
|
||||
### Requirement: Название эмитента при разных названиях бренда
|
||||
Если учитываемые позиции одного `issuer.id` имеют разные `issuer.name`, название элемента SHALL быть `issuer.name` позиции с наибольшим `currentValue` в выбранной валюте. При равных значениях SHALL выбираться название, меньшее по алфавитному порядку требования «Порядок элементов с равной суммой».
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||
|
||||
#### Scenario: Название по наибольшей позиции
|
||||
- **GIVEN** акция `brand-1` на 300 с названием «Сбер» и облигация `brand-1` на 200 с названием «Сбербанк»
|
||||
- **WHEN** User открывает разрез «Эмитенты»
|
||||
- **THEN** один элемент на 500 назван «Сбер»
|
||||
|
||||
#### Scenario: Равные наибольшие значения
|
||||
- **GIVEN** акция `brand-1` на 200 с названием «Сбербанк» и облигация `brand-1` на 200 с названием «Сбер»
|
||||
- **WHEN** User открывает разрез «Эмитенты»
|
||||
- **THEN** элемент назван «Сбер»
|
||||
|
||||
### Requirement: Позиции, учитываемые в разрезе
|
||||
Система SHALL учитывать позицию в ранжировании, долях и диаграмме только при `currentValue` больше нуля в выбранной валюте; остальные позиции SHALL NOT учитываться. Позиция с нулевым `currentValue` не показывается и не считается неучтённой, позиция другой валюты относится к другой валюте выбора.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||
|
||||
#### Scenario: Отрицательная и неоценённая позиции
|
||||
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** сумма разреза 1 000 ₽, доли 60 % и 40 %
|
||||
|
||||
#### Scenario: Позиция с нулевой стоимостью
|
||||
- **GIVEN** есть акции на 100 и 0 в выбранной валюте
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** показана только акция на 100 с долей 100 %, сноски нет
|
||||
|
||||
### Requirement: Сноска о не учтённых позициях
|
||||
Система SHALL показывать сноску «Не учтено позиций: N» при N больше нуля. N — число позиций разреза с `currentValue` `null` (в любой валюте) либо отрицательным в выбранной.
|
||||
|
||||
**Источник (ненормативно):** [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080). Примечание: при неполных данных «Структура портфеля» показывает «Доля недоступна», вкладка — доли по учтённым ([3078](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)).
|
||||
|
||||
#### Scenario: Сноска с числом позиций
|
||||
- **GIVEN** в разрезе «Акции» в RUB есть акции на 600 и 400, акция со стоимостью -50 и акция без `currentValue`
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показана сноска «Не учтено позиций: 2»
|
||||
|
||||
#### Scenario: Неоценённая позиция при смене валюты
|
||||
- **GIVEN** есть акции на 100 USD и 200 RUB и одна акция без `currentValue`
|
||||
- **WHEN** User выбирает USD
|
||||
- **THEN** показаны акция на 100 $ с долей 100 % и сноска «Не учтено позиций: 1»
|
||||
|
||||
#### Scenario: Отрицательная позиция другой валюты
|
||||
- **GIVEN** есть акции на 100 RUB и -50 USD
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** сноски нет
|
||||
|
||||
#### Scenario: Нет не учтённых позиций
|
||||
- **GIVEN** все акции имеют положительную стоимость
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** сноски нет
|
||||
|
||||
### Requirement: Одна валюта за раз
|
||||
Система SHALL показывать значения вкладки в одной выбранной валюте, общей для всех разрезов и сохраняемой при их переключении, и при нескольких доступных валютах предоставлять выбор из них. Смена валюты SHALL пересчитывать разрез по позициям этой валюты. Система SHALL NOT конвертировать валюты и показывать общий итог нескольких валют.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||
|
||||
#### Scenario: Смена валюты
|
||||
- **GIVEN** акции стоят 1 000 RUB и 100 USD, выбрана RUB
|
||||
- **WHEN** User выбирает USD
|
||||
- **THEN** разрез «Акции» показывает только акции в USD, сумму 100 $ и долю 100 %, без рублёвых значений
|
||||
|
||||
#### Scenario: Валюта сохраняется при смене разреза
|
||||
- **GIVEN** выбрана USD в разрезе «Акции»
|
||||
- **WHEN** User переключает разрез на «Облигации»
|
||||
- **THEN** показаны облигации в USD
|
||||
|
||||
### Requirement: Доступные валюты вкладки
|
||||
Доступные валюты — валюты ненулевых `currentValue` позиций `STOCK`, `BOND` и `FUND`, включая отрицательные (для сноски). Валюта только с нулевой стоимостью в список не входит. Cash Balance и другие типы валюты не определяют. Список упорядочен по ISO-коду, сначала выбрана первая. При одной валюте или без валют выбор SHALL NOT показываться.
|
||||
|
||||
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||
|
||||
#### Scenario: Начальная валюта
|
||||
- **GIVEN** акции оценены в USD, облигации в RUB, а Cash Balance есть ещё в EUR и позиция `CURRENCY` оценена в CHF
|
||||
- **WHEN** User впервые открывает вкладку «Концентрация»
|
||||
- **THEN** в выборе RUB и USD, выбрана RUB, EUR и CHF в выборе нет
|
||||
|
||||
#### Scenario: Единственная валюта
|
||||
- **GIVEN** все оценённые акции, облигации и фонды стоят в RUB
|
||||
- **WHEN** User открывает вкладку «Концентрация»
|
||||
- **THEN** выбора валюты нет, значения показаны в RUB
|
||||
|
||||
#### Scenario: Валюта только у позиции с нулевой стоимостью
|
||||
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью 0
|
||||
- **WHEN** User открывает вкладку «Концентрация»
|
||||
- **THEN** в списке только RUB, USD в нём нет
|
||||
|
||||
#### Scenario: Валюта только у отрицательной позиции
|
||||
- **GIVEN** акции стоят 100 RUB, а единственная позиция в USD — акция со стоимостью -50
|
||||
- **WHEN** User выбирает USD в разрезе «Акции»
|
||||
- **THEN** показаны пустое состояние и сноска «Не учтено позиций: 1»
|
||||
|
||||
#### Scenario: Нет оценённых позиций
|
||||
- **GIVEN** у всех позиций `STOCK`, `BOND` и `FUND` `currentValue` равен `null`
|
||||
- **WHEN** User открывает вкладку «Концентрация»
|
||||
- **THEN** выбора валюты нет, разрез показывает пустое состояние без валюты и сноску с числом таких позиций
|
||||
|
||||
### Requirement: Независимый выбор валюты вкладки
|
||||
Выбор валюты вкладки «Концентрация» SHALL быть независим от выбора валюты в «Структуре портфеля»: смена валюты в одном месте SHALL NOT менять валюту в другом. Если выбранная валюта вкладки пропала из списка после обновления Portfolio, система SHALL выбрать первую валюту списка.
|
||||
|
||||
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||
|
||||
#### Scenario: Структура портфеля не влияет
|
||||
- **GIVEN** в «Структуре портфеля» выбрана USD, а на вкладке «Концентрация» выбрана RUB
|
||||
- **WHEN** User меняет валюту в «Структуре портфеля» на RUB и обратно
|
||||
- **THEN** выбранная на вкладке валюта остаётся RUB
|
||||
|
||||
#### Scenario: Валюта пропала после обновления
|
||||
- **GIVEN** на вкладке выбрана USD
|
||||
- **WHEN** обновлённый Portfolio не содержит позиций `STOCK`, `BOND` и `FUND` в USD
|
||||
- **THEN** выбрана первая валюта обновлённого списка
|
||||
|
||||
### Requirement: Топ-5 и строка «Прочее»
|
||||
Система SHALL упорядочивать элементы разреза по сумме по убыванию. Если элементов не больше шести, каждый SHALL показываться строкой с названием, суммой и долей. Если семь и больше, SHALL показываться пять первых строк и строка «Прочее» с суммой, долей и числом остальных элементов («2 элемента», «5 элементов»).
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079)
|
||||
|
||||
#### Scenario: Семь акций
|
||||
- **GIVEN** в RUB есть семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показаны пять строк с суммами 400, 250, 150, 100, 50 и строка «Прочее» 50 ₽ (2 элемента) с долей 5 %
|
||||
|
||||
#### Scenario: Ровно шесть элементов
|
||||
- **GIVEN** в RUB есть шесть акций с суммами 300, 250, 200, 150, 60 и 40
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показаны шесть строк с долями 30 %, 25 %, 20 %, 15 %, 6 % и 4 %, строки «Прочее» нет
|
||||
|
||||
#### Scenario: Ровно пять элементов
|
||||
- **GIVEN** в RUB есть пять акций с суммами 300, 250, 200, 150 и 100
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показаны пять строк, строки «Прочее» нет
|
||||
|
||||
#### Scenario: Меньше пяти элементов
|
||||
- **GIVEN** в RUB есть три акции
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** показаны три строки, строки «Прочее» нет
|
||||
|
||||
### Requirement: Раскрытие строки «Прочее»
|
||||
По нажатию на «Прочее», в том числе с клавиатуры, система SHALL раскрывать полный список остальных элементов в порядке ранжирования с суммой и долей; повторное нажатие сворачивает его. Раскрытие SHALL NOT менять пять первых строк и диаграмму. При показе разреза и смене валюты «Прочее» SHALL быть свёрнуто.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3077)
|
||||
|
||||
#### Scenario: Раскрытие «Прочее»
|
||||
- **GIVEN** показан разрез «Акции» из семи акций (400, 250, 150, 100, 50, 30, 20), «Прочее» свёрнуто
|
||||
- **WHEN** User нажимает на строку «Прочее»
|
||||
- **THEN** видны две акции с суммами 30 ₽ и 20 ₽ и долями 3 % и 2 %, пять первых строк прежние
|
||||
|
||||
#### Scenario: Повторное нажатие
|
||||
- **GIVEN** «Прочее» раскрыто
|
||||
- **WHEN** User нажимает на него ещё раз
|
||||
- **THEN** список остальных элементов скрыт
|
||||
|
||||
#### Scenario: Смена валюты сворачивает «Прочее»
|
||||
- **GIVEN** «Прочее» раскрыто в RUB
|
||||
- **WHEN** User выбирает USD
|
||||
- **THEN** «Прочее» свёрнуто
|
||||
|
||||
### Requirement: Порядок элементов с равной суммой
|
||||
Элементы разреза с равной суммой SHALL упорядочиваться по названию по возрастанию без учёта регистра: сначала названия, начинающиеся с цифры, затем с латинской буквы, затем с кириллической; внутри группы — по алфавиту, при совпадении названия — по порядку первой позиции элемента в Portfolio.
|
||||
|
||||
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||
|
||||
#### Scenario: Равные суммы на границе пятой строки
|
||||
- **GIVEN** в RUB семь акций: 400, 300, 200, 150, «Альфа» 100, «Бета» 100 и 50
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** «Альфа» пятая строка, а «Бета» и акция на 50 входят в «Прочее»
|
||||
|
||||
#### Scenario: Цифры, латиница и кириллица при равных суммах
|
||||
- **GIVEN** в RUB три акции с равной суммой 100 и названиями «Сбер», «VK» и «X5»
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** порядок строк: «VK», «X5», «Сбер»
|
||||
|
||||
### Requirement: Расчёт долей
|
||||
Точная доля элемента — сумма его учитываемых позиций, делённая на сумму всех учитываемых позиций разреза в выбранной валюте; Cash Balance в расчёте не участвует. Точные доли элементов SHALL давать в сумме ровно 100 %, точная доля «Прочее» — равняться сумме точных долей остальных элементов, суммы — быть без округления.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||
|
||||
#### Scenario: Доля без денежных остатков
|
||||
- **GIVEN** акции на 600 RUB и 400 RUB и Cash Balance 1 000 RUB
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** доли 60 % и 40 %
|
||||
|
||||
#### Scenario: Доля «Прочее»
|
||||
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
|
||||
- **THEN** доля «Прочее» 5 % равна сумме долей 3 % и 2 %
|
||||
|
||||
### Requirement: Показ долей
|
||||
Доля SHALL показываться, как в карточке позиции: округление до десятых в русской локали, середина — вверх (6,25 % — «6,3 %»), без «,0» («40 %», «66,7 %»). Положительная доля меньше 0,1 % показывается как «< 0,1 %». Суммарная доля топ-5 — в том же формате; сумма показанных долей может не быть 100 %.
|
||||
|
||||
**Источник (ненормативно):** [3079](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3079), [3080](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||
|
||||
#### Scenario: Три равные доли
|
||||
- **GIVEN** три акции «А», «Б» и «В» по 100 RUB
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** каждая доля показана как «33,3 %»
|
||||
|
||||
#### Scenario: Округление, сумма показанных долей не равна 100 %
|
||||
- **GIVEN** две акции на 1 RUB и 15 RUB, точные доли 6,25 % и 93,75 %
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** доли показаны как «93,8 %» и «6,3 %», хотя в сумме дают 100,1 %
|
||||
|
||||
#### Scenario: Очень малая доля
|
||||
- **GIVEN** акции на 100 000 000 RUB и 1 RUB
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** доли показаны как «100 %» и «< 0,1 %», сумма второй акции остаётся 1 ₽
|
||||
|
||||
### Requirement: Суммарная доля топ-5
|
||||
Система SHALL показывать метрику «доля топ-5» — сумму точных долей пяти первых по сумме элементов разреза, в формате требования «Показ долей» — только когда элементов шесть и более. При пяти и менее элементах метрика SHALL NOT показываться.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||
|
||||
#### Scenario: Семь элементов
|
||||
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** доли строк 40 %, 25 %, 15 %, 10 %, 5 % и «Прочее» 5 %, доля топ-5 равна 95 %
|
||||
|
||||
#### Scenario: Ровно шесть элементов
|
||||
- **GIVEN** в RUB шесть акций с суммами 300, 250, 200, 150, 60 и 40
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** доля топ-5 равна 96 %, а шестая строка показана с долей 4 %
|
||||
|
||||
#### Scenario: Ровно пять элементов
|
||||
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** метрика «доля топ-5» не показывается
|
||||
|
||||
#### Scenario: Один элемент
|
||||
- **GIVEN** в RUB одна акция
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** метрика «доля топ-5» не показывается
|
||||
|
||||
### Requirement: Группы «Сектор не указан» и «Эмитент не указан»
|
||||
Система SHALL объединять учитываемые позиции с `sector: null` («Секторы») в элемент «Сектор не указан», а с `issuer: null` («Эмитенты») — в элемент «Эмитент не указан». Эти элементы SHALL ранжироваться и попадать в «Прочее» наравне с остальными, а их название SHALL быть видно в списке и диаграмме. Сектор и эмитент SHALL NOT выводиться из названия.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3078)
|
||||
|
||||
#### Scenario: Позиции без эмитента в топе
|
||||
- **GIVEN** в RUB акции эмитента «Сбер» на 300, эмитента «Газпром» на 300 и без эмитента на 200 и 100
|
||||
- **WHEN** User открывает разрез «Эмитенты» в RUB
|
||||
- **THEN** по убыванию: «Газпром» 300, «Сбер» 300, «Эмитент не указан» 300 (две позиции), группа ранжируется наравне с другими
|
||||
|
||||
#### Scenario: Группа без сектора в «Прочее»
|
||||
- **GIVEN** у шести секторов суммы 600, 500, 400, 300, 200 и 100, у позиций без сектора сумма 50
|
||||
- **WHEN** User раскрывает «Прочее» в разрезе «Секторы»
|
||||
- **THEN** «Прочее» содержит шестой сектор 100 ₽ и «Сектор не указан» 50 ₽
|
||||
|
||||
### Requirement: Подписи секторов
|
||||
Система SHALL показывать известное значение `sector` русской подписью по требованию «Русские подписи известных секторов», неизвестное — как получено, без перевода и пояснений, в списке и текстовом эквиваленте диаграммы. Система SHALL NOT придумывать подписи сверх таблицы и собственную классификацию и SHALL NOT объединять позиции с разными `sector`.
|
||||
|
||||
**Источник (ненормативно):** [#48, подтверждено](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3080)
|
||||
|
||||
#### Scenario: Неизвестное значение сектора
|
||||
- **GIVEN** облигация имеет `sector` `other`, которого нет в таблице подписей
|
||||
- **WHEN** User открывает разрез «Секторы»
|
||||
- **THEN** элемент назван `other`
|
||||
|
||||
### Requirement: Русские подписи известных секторов
|
||||
Система SHALL показывать `sector` подписью по таблице.
|
||||
|
||||
| Значение | Подпись |
|
||||
| --- | --- |
|
||||
| `consumer` | Потребительский сектор |
|
||||
| `energy` | Энергетика |
|
||||
| `financial` | Финансы |
|
||||
| `health_care` | Здравоохранение |
|
||||
| `industrials` | Промышленность |
|
||||
| `it` | Информационные технологии |
|
||||
| `materials` | Материалы |
|
||||
| `real_estate` | Недвижимость |
|
||||
| `telecom` | Телекоммуникации |
|
||||
| `utilities` | Коммунальные услуги |
|
||||
| `government` | Государственный сектор |
|
||||
|
||||
|
||||
#### Scenario: Известный сектор
|
||||
- **GIVEN** позиция имеет `sector` `it`
|
||||
- **WHEN** User открывает разрез «Секторы»
|
||||
- **THEN** элемент назван «Информационные технологии»
|
||||
|
||||
#### Scenario: Государственные облигации
|
||||
- **GIVEN** облигация имеет `sector` `government`
|
||||
- **WHEN** User открывает разрез «Секторы»
|
||||
- **THEN** элемент назван «Государственный сектор»
|
||||
|
||||
### Requirement: Диаграмма распределения разреза
|
||||
Система SHALL показывать диаграмму разреза с теми же элементами, в том же порядке и с теми же долями, что в списке: для разреза не более чем из шести элементов — сегмент на каждый элемент, для разреза из семи и более — пять сегментов и один сегмент «Прочее», одинаково для свёрнутого и раскрытого списка. Для пустого разреза диаграмма SHALL NOT показываться. Не учтённые позиции SHALL NOT входить в диаграмму.
|
||||
|
||||
#### Scenario: Семь элементов
|
||||
- **GIVEN** семь акций с суммами 400, 250, 150, 100, 50, 30 и 20 RUB
|
||||
- **WHEN** User открывает разрез «Акции» и раскрывает «Прочее»
|
||||
- **THEN** диаграмма состоит из шести сегментов с долями 40 %, 25 %, 15 %, 10 %, 5 %, 5 % в порядке списка, как и при свёрнутом «Прочее»
|
||||
|
||||
#### Scenario: Ровно шесть элементов
|
||||
- **GIVEN** шесть акций с суммами 300, 250, 200, 150, 60 и 40 RUB
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** диаграмма состоит из шести сегментов с долями 30 %, 25 %, 20 %, 15 %, 6 %, 4 %
|
||||
|
||||
### Requirement: Центр диаграммы
|
||||
Система SHALL показывать в центре диаграммы сумму разреза — точную сумму учитываемых позиций в выбранной валюте, а над ней, когда показывается метрика «доля топ-5», подпись «Топ-5 · N %», где N — доля в формате «Показ долей». Без метрики показывается только сумма. Эти значения SHALL входить в текстовый эквивалент диаграммы.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||
|
||||
#### Scenario: Центр при семи акциях
|
||||
- **GIVEN** в RUB семь акций с суммами 400, 250, 150, 100, 50, 30 и 20
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** в центре «Топ-5 · 95 %» и «1 000 ₽»
|
||||
|
||||
#### Scenario: Центр при пяти акциях
|
||||
- **GIVEN** в RUB пять акций с суммами 300, 250, 200, 150 и 100
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** в центре только «1 000 ₽»
|
||||
|
||||
### Requirement: Строки без переходов
|
||||
Строка списка в любом разрезе SHALL NOT вести на другую страницу; единственное действие строки — раскрытие и сворачивание «Прочее». Строки разреза «Эмитенты» SHALL NOT содержать логотипов.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081)
|
||||
|
||||
#### Scenario: Нажатие на строку акции
|
||||
- **WHEN** User нажимает на строку акции в разрезе «Акции»
|
||||
- **THEN** страница не меняется
|
||||
|
||||
#### Scenario: Строка эмитента
|
||||
- **WHEN** User открывает разрез «Эмитенты»
|
||||
- **THEN** строки показаны без логотипов
|
||||
|
||||
### Requirement: Доступность диаграммы без цвета
|
||||
Информация диаграммы SHALL NOT передаваться только цветом: каждый сегмент SHALL быть сопоставим со строкой списка по названию и доле, доступным как текст. Диаграмма SHALL иметь текстовый эквивалент с названиями, суммами и долями сегментов в порядке списка, доступный вспомогательным технологиям.
|
||||
|
||||
#### Scenario: Сегменты различимы без цвета
|
||||
- **GIVEN** показан разрез из шести строк
|
||||
- **WHEN** User читает диаграмму вспомогательной технологией
|
||||
- **THEN** доступны название, сумма и доля каждого сегмента в порядке списка, сопоставление со строкой не требует различения цветов
|
||||
|
||||
### Requirement: Пустое состояние разреза
|
||||
Если выбранный разрез не содержит учитываемых элементов, система SHALL показывать пустое состояние с названием разреза и выбранной валютой (если валюты доступны) вместо списка, суммарной доли и диаграммы. Выбор валюты, если он показывается, и сноска при N больше нуля SHALL оставаться доступными.
|
||||
|
||||
#### Scenario: Нет облигаций в выбранной валюте
|
||||
- **GIVEN** есть акции в RUB и облигации только в USD
|
||||
- **WHEN** User открывает разрез «Облигации» в RUB
|
||||
- **THEN** показано пустое состояние, выбор валюты доступен (валют две)
|
||||
|
||||
#### Scenario: Все позиции разреза отрицательные
|
||||
- **GIVEN** в RUB есть только одна акция со стоимостью -50
|
||||
- **WHEN** User открывает разрез «Акции» в RUB
|
||||
- **THEN** показано пустое состояние и сноска «Не учтено позиций: 1»
|
||||
|
||||
### Requirement: Предупреждение о неполных данных
|
||||
Если `meta` обозначает неполноту по «Результат личного обновления остаётся наблюдаемым» из `tbank-data-sync`, вкладка SHALL показывать предупреждение «Данные неполные» над разрезом, считая разрезы по доступным данным. Страница SHALL NOT дублировать его; позиции без оценки его не вызывают.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||
|
||||
#### Scenario: Неполный Portfolio
|
||||
- **GIVEN** `meta` обозначает неполноту, акции стоят 600 и 400 RUB
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** показаны предупреждение «Данные неполные» и доли 60 % и 40 %
|
||||
|
||||
#### Scenario: Предупреждение не дублируется
|
||||
- **GIVEN** `meta` обозначает неполноту
|
||||
- **WHEN** User открывает вкладку «Концентрация»
|
||||
- **THEN** на странице одно сообщение о неполноте — «Данные неполные» на вкладке
|
||||
|
||||
#### Scenario: Полный Portfolio с неоценённой позицией
|
||||
- **GIVEN** Portfolio полный, одна акция без `currentValue`
|
||||
- **WHEN** User открывает разрез «Акции»
|
||||
- **THEN** предупреждения «Данные неполные» нет, есть сноска «Не учтено позиций: 1»
|
||||
|
||||
### Requirement: Состояния Portfolio на вкладке «Концентрация»
|
||||
Загрузка, ошибка, устаревание и неполнота Portfolio на вкладке «Концентрация» SHALL соответствовать требованию «Состояния и ошибки Portfolio на клиенте» из `portfolio-view`, как на вкладке «Позиции». Отсутствие сектора или эмитента у позиций SHALL NOT скрывать вкладку и менять успешность и свежесть Portfolio.
|
||||
|
||||
#### Scenario: Устаревший Portfolio
|
||||
- **GIVEN** клиент получает Portfolio с `isStale: true`
|
||||
- **WHEN** User открывает вкладку «Концентрация»
|
||||
- **THEN** разрезы рассчитаны по этому Portfolio, устаревание обозначено так же, как на вкладке «Позиции»
|
||||
|
||||
#### Scenario: Сектор и эмитент отсутствуют
|
||||
- **GIVEN** у всех позиций `sector: null` и `issuer: null`, Portfolio успешен
|
||||
- **WHEN** User открывает разрезы «Секторы» и «Эмитенты»
|
||||
- **THEN** каждый разрез содержит один элемент «Сектор не указан» или «Эмитент не указан» с долей 100 %
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Нормализованный состав Portfolio
|
||||
Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Portfolio Position с типом `CURRENCY` MUST означать позицию по торгуемому валютному Instrument и MUST NOT смешиваться с Cash Balance. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio.
|
||||
|
||||
Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice`, nullable `absoluteReturn`, nullable `isin`, nullable `ticker` и nullable `logoName` — имя файла логотипа бренда инструмента, nullable `sector` — сектор инструмента и nullable `issuer` — эмитент позиции. Имя файла логотипа MUST заполняться из metadata инструмента, когда upstream предоставляет бренд с непустым именем файла логотипа; во всех остальных случаях оно MUST быть `null`. Логотип является presentation metadata: он не влияет на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot. Поле `sector` позиции типа `STOCK`, `BOND` или `FUND` MUST содержать значение сектора из metadata инструмента в том виде, как его определяет T-Bank, без перекодирования и собственной классификации, с обрезанными пробелами в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Поле `issuer` позиции типа `STOCK`, `BOND` или `FUND`, включая фонд, MUST быть объектом с непустыми строками `id` — идентификатор бренда актива инструмента — и `name` — название этого бренда (не название компании), когда T-Bank предоставляет для инструмента бренд актива с идентификатором и названием, непустыми после обрезки пробелов в начале и конце; значение из одних пробелов считается отсутствующим. Во всех остальных случаях, включая остальные типы позиций, оно MUST быть `null`. Система MUST NOT определять эмитента по совпадению названия, тикера или ISIN. Сектор и эмитент не влияют на количество, стоимость, агрегаты, успешность или свежесть Portfolio Snapshot: их отсутствие либо недоступность MUST NOT делать Snapshot неуспешным или устаревшим и MUST NOT скрывать позицию или её финансовые значения, кроме отказа token по требованию «Свежесть и fallback полного Portfolio Snapshot». Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения.
|
||||
|
||||
#### Scenario: Позиция с неизвестным upstream-типом
|
||||
- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента
|
||||
- **THEN** система возвращает её в исходном порядке с типом `OTHER`
|
||||
|
||||
#### Scenario: Позиция валютного инструмента не является денежным остатком
|
||||
- **WHEN** upstream возвращает ненулевую Portfolio Position валютного инструмента и ненулевой Cash Balance той же валюты
|
||||
- **THEN** система возвращает Portfolio Position с типом `CURRENCY` отдельно от Cash Balance и не объединяет их между собой
|
||||
|
||||
#### Scenario: Позиция без metadata инструмента
|
||||
- **WHEN** lookup metadata по FIGI не дал ticker или name либо временно недоступен
|
||||
- **THEN** система возвращает позицию с FIGI как fallback-идентификатором и всеми доступными финансовыми значениями
|
||||
|
||||
#### Scenario: Имя инструмента доступно
|
||||
- **WHEN** metadata инструмента по FIGI содержит непустое name
|
||||
- **THEN** система возвращает name как отображаемый идентификатор позиции вместо FIGI или ticker
|
||||
|
||||
#### Scenario: FIGI не разрешается, но доступен instrument UID
|
||||
- **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name
|
||||
- **THEN** система возвращает name, полученное по instrument UID
|
||||
|
||||
#### Scenario: ISIN инструмента доступен
|
||||
- **WHEN** metadata инструмента позиции содержит непустой `isin`
|
||||
- **THEN** система возвращает его в поле `isin` позиции
|
||||
|
||||
#### Scenario: ISIN инструмента отсутствует
|
||||
- **WHEN** у инструмента позиции нет ISIN либо metadata недоступна
|
||||
- **THEN** система возвращает позицию с `isin: null`, не скрывая финансовые значения
|
||||
|
||||
#### Scenario: Тикер инструмента доступен
|
||||
- **WHEN** metadata инструмента позиции содержит непустой `ticker`
|
||||
- **THEN** система возвращает его в поле `ticker` позиции
|
||||
|
||||
#### Scenario: Тикер инструмента отсутствует
|
||||
- **WHEN** у инструмента позиции нет ticker либо metadata недоступна
|
||||
- **THEN** система возвращает позицию с `ticker: null`, не скрывая финансовые значения
|
||||
|
||||
#### Scenario: Имя файла логотипа бренда доступно
|
||||
- **WHEN** metadata инструмента позиции содержит бренд с непустым именем файла логотипа
|
||||
- **THEN** система возвращает его в поле `logoName` позиции
|
||||
|
||||
#### Scenario: Имя файла логотипа бренда отсутствует
|
||||
- **WHEN** у инструмента позиции нет бренда, имя файла логотипа пустое либо metadata недоступна
|
||||
- **THEN** система возвращает позицию с `logoName: null`, не скрывая финансовые значения
|
||||
|
||||
#### Scenario: Денежные остатки в одной валюте
|
||||
- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты
|
||||
- **THEN** система возвращает один Cash Balance с их точной суммой
|
||||
|
||||
#### Scenario: Штатный пустой Portfolio
|
||||
- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков
|
||||
- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency`
|
||||
|
||||
#### Scenario: Сектор инструмента доступен
|
||||
- **WHEN** metadata инструмента позиции содержит сектор `energy`
|
||||
- **THEN** система возвращает в поле `sector` позиции значение `energy` без изменений
|
||||
|
||||
#### Scenario: Сектор инструмента отсутствует
|
||||
- **WHEN** у инструмента позиции нет сектора, сектор пустой либо metadata недоступна
|
||||
- **THEN** система возвращает позицию с `sector: null`, не скрывая финансовые значения
|
||||
|
||||
#### Scenario: Эмитент позиции доступен
|
||||
- **WHEN** T-Bank предоставляет для инструмента позиции бренд актива с идентификатором `brand-1` и названием «Сбер»
|
||||
- **THEN** система возвращает в поле `issuer` позиции идентификатор `brand-1` и название «Сбер»
|
||||
|
||||
#### Scenario: Название эмитента — название бренда
|
||||
- **WHEN** T-Bank предоставляет для инструмента бренд актива с названием «Сбер» и компанией «ПАО Сбербанк»
|
||||
- **THEN** система возвращает в `issuer.name` «Сбер»
|
||||
|
||||
#### Scenario: Эмитент у фонда
|
||||
- **WHEN** T-Bank предоставляет для инструмента позиции `FUND` бренд актива с идентификатором `brand-2` и названием «Фонд»
|
||||
- **THEN** система возвращает в `issuer` позиции идентификатор `brand-2` и название «Фонд»
|
||||
|
||||
#### Scenario: Пробелы по краям обрезаются
|
||||
- **WHEN** T-Bank возвращает сектор " energy " и название бренда " Сбер "
|
||||
- **THEN** система возвращает `sector` `energy` и `issuer.name` «Сбер»
|
||||
|
||||
#### Scenario: Значение из одних пробелов
|
||||
- **WHEN** T-Bank возвращает сектор " " либо название бренда " "
|
||||
- **THEN** система возвращает `sector: null` либо `issuer: null` соответственно
|
||||
|
||||
#### Scenario: Остальные типы позиций
|
||||
- **WHEN** T-Bank предоставляет сектор и бренд актива для инструмента позиции типа `FUTURE`
|
||||
- **THEN** система возвращает у этой позиции `sector: null` и `issuer: null`
|
||||
|
||||
#### Scenario: Эмитент позиции отсутствует
|
||||
- **WHEN** у инструмента позиции нет бренда актива, идентификатор либо название бренда пустые или данные бренда недоступны
|
||||
- **THEN** система возвращает позицию с `issuer: null`, не скрывая финансовые значения
|
||||
|
||||
#### Scenario: Эмитент не определяется по названию
|
||||
- **WHEN** две позиции имеют одинаковое название, но разные идентификаторы бренда актива
|
||||
- **THEN** система возвращает в `issuer` позиций разные идентификаторы бренда и не объединяет их
|
||||
|
||||
#### Scenario: Недоступность сектора и эмитента не нарушает Snapshot
|
||||
- **GIVEN** сектор и бренд актива позиции ещё ни разу не получены
|
||||
- **WHEN** получение сектора и бренда актива завершается ошибкой, а количества и цены позиции получены
|
||||
- **THEN** система сохраняет Snapshot с `sector: null` и `issuer: null` у затронутых позиций; их `currentValue`, `totalsByCurrency`, `lastSuccessfulAt` и `isStale` такие же, как при доступных сектора и бренде
|
||||
|
||||
### Requirement: Свежесть и fallback полного Portfolio Snapshot
|
||||
Система MUST хранить последний полный успешно полученный Portfolio Snapshot с принадлежностью User, экземпляру подключения и Brokerage Account. Обычный GET MUST возвращать `{ data, meta }`, где `data` является сохранённым Snapshot, а `meta` соответствует наблюдаемому состоянию resource по `tbank-data-sync`, читать его без ожидания T-Bank и MUST NOT запускать обновление личного Portfolio, даже если Snapshot отсутствует или старше 60 минут; обновлением владеет часовой обход. Portfolio source MUST считаться свежим в течение 60 минут после успешного получения. Ошибка обхода MUST сохранять прежний полный Snapshot и его время без лимита fallback. Если состав получен полностью, но отдельная позиция не имеет цены, система MUST сохранить новый состав с неполной оценкой; metadata MUST NOT определять успешность или свежесть финансового Snapshot, кроме отказа token при получении сектора или бренда актива позиции: он MUST обрабатываться так же, как отказ token при обходе Portfolio (исключение, источник — [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)). Сохранённая доступная identity позиции MAY оставаться доступной при недоступности online-справочника.
|
||||
|
||||
Система MUST делать прежние личные datasets недоступными после успешной замены или удаления подключения. Поздняя работа прежнего подключения MUST NOT публиковаться в новом. Самостоятельные справочные сведения и результаты lookup MUST запрашиваться онлайн, а не сохраняться как долговременный справочник; ошибка lookup не подтверждает отсутствие инструмента. Исключение: сектор и бренд актива позиции MUST храниться как последнее известное значение и обновляться по требованию «Обновление сектора и эмитента» ([#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)).
|
||||
|
||||
#### Scenario: Свежий Snapshot
|
||||
- **WHEN** User повторно запрашивает тот же Portfolio в течение 60 минут после успешного получения
|
||||
- **THEN** система возвращает свежий Snapshot без нового обращения к T-Bank
|
||||
|
||||
#### Scenario: Медленный lookup инструмента
|
||||
- **WHEN** сведения об инструменте ещё запрашиваются онлайн
|
||||
- **THEN** система не задерживает локальный ответ Portfolio ради lookup и показывает сохранённую доступную identity
|
||||
|
||||
#### Scenario: Fallback после временной upstream-ошибки
|
||||
- **WHEN** часовой обход Portfolio неуспешен из-за временной недоступности T-Bank, а в текущей границе подключения есть полный успешный Snapshot
|
||||
- **THEN** система отвечает `200` этим Snapshot с `isStale: true` и исходным `lastSuccessfulAt`
|
||||
|
||||
#### Scenario: Недействительный token не маскируется fallback-данными
|
||||
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при часовом обходе Portfolio
|
||||
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`
|
||||
|
||||
#### Scenario: Неполная оценка при новом составе
|
||||
- **WHEN** успешно получен полный состав позиций, но у одной позиции нет вычислимой цены
|
||||
- **THEN** новый состав становится текущим Portfolio Snapshot с неизвестной стоимостью позиции без подстановки нуля
|
||||
|
||||
#### Scenario: Сектор и бренд читаются из хранимого значения
|
||||
- **GIVEN** сектор позиции получен 10 часов назад
|
||||
- **WHEN** User запрашивает Portfolio
|
||||
- **THEN** система возвращает хранимый `sector` без обращения к T-Bank за сектором
|
||||
|
||||
#### Scenario: Отказ token при получении бренда
|
||||
- **WHEN** T-Bank отклоняет token как неаутентифицированный или без прав при получении сектора или бренда актива позиции
|
||||
- **THEN** система не возвращает fallback Snapshot и отвечает `400` Problem Details с code `INVALID_TBANK_TOKEN`, как при отказе token в обходе Portfolio
|
||||
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Обновление сектора и эмитента
|
||||
Система SHALL обновлять сектор и бренд актива позиции при первом обходе по `hourly-investment-sync`, начавшемся не ранее чем через 24 часа после последнего успешного получения; в остальных обходах значение не меняется. Пока значение не получено, получение повторяется при каждом обходе.
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||
|
||||
#### Scenario: Обход раньше 24 часов
|
||||
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
|
||||
- **WHEN** обход начинается во вторник в 08:59, то есть через 23 ч 59 мин
|
||||
- **THEN** `issuer.name` остаётся прежним
|
||||
|
||||
#### Scenario: Обход через 24 часа
|
||||
- **GIVEN** бренд позиции получен в понедельник в 09:00, а затем T-Bank сменил его название
|
||||
- **WHEN** обход начинается во вторник в 09:00
|
||||
- **THEN** `issuer.name` новое
|
||||
|
||||
### Requirement: Результат получения сектора и эмитента
|
||||
Результат получения SHALL определять поле позиции и отсчёт 24 часов по таблице; совпадает ровно одна строка.
|
||||
|
||||
| Исход получения | Поле | Отсчёт 24 часов |
|
||||
| --- | --- | --- |
|
||||
| Получено значение | Новое значение | Начинается заново |
|
||||
| Ответ «не найдено» или значения нет | `null` | Начинается заново |
|
||||
| Ответ о другом инструменте или активе | Прежнее значение или `null` | Не меняется; получение неуспешно |
|
||||
| Иная ошибка, кроме отказа token | Прежнее значение или `null` | Не меняется; получение неуспешно |
|
||||
|
||||
Неуспешное получение SHALL NOT мешать обновлению остальных данных Portfolio; отказ token — по «Свежесть и fallback полного Portfolio Snapshot».
|
||||
|
||||
**Источник (ненормативно):** [#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)
|
||||
|
||||
#### Scenario: Ответ без значения заменяет известное значение
|
||||
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||
- **WHEN** T-Bank отвечает, что сектора у инструмента нет
|
||||
- **THEN** `sector: null`
|
||||
|
||||
#### Scenario: «Не найдено» перезапускает отсчёт
|
||||
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли, и обход получает от T-Bank «не найдено»
|
||||
- **WHEN** следующий обход начинается через час, а T-Bank уже возвращает сектор `financial`
|
||||
- **THEN** `sector` остаётся `null`; значение `financial` появляется при первом обходе, начавшемся не ранее чем через 24 часа после ответа «не найдено»
|
||||
|
||||
#### Scenario: Ответ о другом инструменте
|
||||
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||
- **WHEN** T-Bank возвращает данные другого инструмента
|
||||
- **THEN** `sector` остаётся `energy`, а следующий обход повторяет получение
|
||||
|
||||
#### Scenario: Недоступность T-Bank сохраняет известное значение
|
||||
- **GIVEN** у позиции известен `sector` `energy`, 24 часа прошли
|
||||
- **WHEN** получение сектора завершается недоступностью T-Bank, а цены и количества получены
|
||||
- **THEN** позиция возвращается с `sector` `energy`, остальные данные Portfolio обновлены, `lastSuccessfulAt` новый
|
||||
|
||||
#### Scenario: Значение ещё не известно
|
||||
- **GIVEN** новая позиция, сектор которой ни разу не получен успешно
|
||||
- **WHEN** получение сектора неуспешно при одном обходе и успешно при следующем
|
||||
- **THEN** после первого обхода `sector: null`, после следующего значение сектора
|
||||
@@ -0,0 +1,187 @@
|
||||
# Tasks
|
||||
|
||||
Issue-координатор: [WealthLedger/main#48](https://git.personal.ksv741.ru/WealthLedger/main/issues/48). В каждом affected repo ветка `feature/48-account-top-positions` создаётся от актуального `develop`; PR открывается в `develop`. Тело PR: Summary, Linked Issue `WealthLedger/main#48`, Changes, Verification, ссылки на связанные PR других repo — по `.ai/conventions/engineering-workflow.md` («Analysis → Branch», «Validation → PR»). Обозначения **PC**, **PVΔ**: PC — `portfolio-concentration`, PVΔ — delta `portfolio-view`.
|
||||
|
||||
## 1. Backend: хранение, классификация, DTO, OpenAPI
|
||||
|
||||
- [x] 1.1 [backend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch», «Branch → Implementation»
|
||||
- **Verification:** `git -C backend branch --show-current` выводит `feature/48-account-top-positions`; `git log origin/develop..HEAD` пуст до первой задачи
|
||||
- **Depends on:** none
|
||||
|
||||
- [x] 1.2 [backend] Migration: аддитивная модель `TbankInstrumentClassification` и миграция `CREATE TABLE`
|
||||
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
|
||||
- **Source:** design.md «Data / Storage Changes», B1; «Migration / Rollout / Rollback»
|
||||
- **Verification:** `npx prisma validate`; `npx prisma generate`; `npm run typecheck:app`; миграция применена к disposable копии БД с существующими Snapshot, прежние таблицы и строки не изменились; `DROP TABLE "TbankInstrumentClassification"` на копии возвращает её к исходной схеме
|
||||
- **Depends on:** 1.1
|
||||
|
||||
- [x] 1.3 [backend] Implementation: `TbankClient.getEtf`, `getAssetBrand` и opt-in NOT_FOUND для новых вызовов классификации
|
||||
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`
|
||||
- **Source:** design.md B4 «Новые методы», «NOT_FOUND»; analysis/data-mapping.md «Источники-кандидаты в T-Bank»
|
||||
- **Verification:** `npm test -- src/integrations/tbank/tbank.client.spec.ts`: NOT_FOUND даёт `NotFoundException` для `bondBy`/`etfBy`/`getAssetBy` только при opt-in; прежние `getBond`/`getAssetDescription` по-прежнему дают Unavailable; `PERMISSION_DENIED` даёт `InvalidToken`
|
||||
- **Depends on:** 1.1
|
||||
|
||||
- [x] 1.4 [backend] Implementation: `PositionClassificationService` — правило 24 ч, четыре исхода по каждому полю, UID→FIGI после NOT_FOUND, дедупликация по инструменту и `asset_uid`
|
||||
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** design.md B3, B4, B5; analysis/failure-model.md «Классификация отказов по исходам PVΔ «Результат получения сектора и эмитента»», F02–F12
|
||||
- **Verification:** `npm test -- src/modules/investments/portfolio`: границы 23:59 и 24:00 от `walkStartedAt`; при отсутствии значения получение идёт в каждом обходе; 4 исхода × {sector, brand}; mismatch не меняет поле; повтор по FIGI после NOT_FOUND по UID; один `GetAssetBy` на общий `asset_uid`; используется сохранённый `assetUid`; `InvalidToken` пробрасывается, остальные ошибки — нет; типы кроме STOCK/BOND/FUND не запрашиваются
|
||||
- **Depends on:** 1.2, 1.3
|
||||
|
||||
- [x] 1.5 [backend] Implementation: шаг классификации в `PortfolioService.queueSnapshot` до publish; `walkStartedAt` из `TbankHourlyScheduler`; mapper пишет `sector`/`issuer`
|
||||
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** design.md B2, B3; analysis/failure-model.md F01, F08
|
||||
- **Verification:** `npm test -- src/modules/investments/portfolio/application/portfolio.service.spec.ts`: отказ token в классификации → Snapshot не опубликован, вызван `rejectMembershipToken`; прочая ошибка → Snapshot опубликован, `lastSuccessfulAt` и `isStale` не изменены, поля — прежние известные; ошибка GetPortfolio → публикации нет независимо от классификации
|
||||
- **Depends on:** 1.4
|
||||
|
||||
- [x] 1.6 [backend] Implementation: DTO `sector` и `PortfolioIssuerDto` (nullable), нормализация старых Snapshot в `saved()`
|
||||
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** design.md B6, B7; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`», «Совместимость и миграция»
|
||||
- **Verification:** `npm run test:e2e`: GET `/api/v1/brokerage-accounts/{accountId}/portfolio` и `/all/portfolio` возвращают у каждой позиции `sector` и `issuer`; для payload без этих полей — `null`; после отказа token — `400 INVALID_TBANK_TOKEN`
|
||||
- **Depends on:** 1.5
|
||||
|
||||
- [x] 1.7 [backend] Contract Parity: OpenAPI-artifact с новыми полями совпадает с target-delta
|
||||
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** design.md «Contract Implementation»; analysis/system-analysis.md «Семантическая delta `PortfolioPositionDto`»
|
||||
- **Verification:** `npm run openapi:generate` и `npm run test:openapi-contract` проходят; в корневом `openapi.json` у `PortfolioPositionDto` поля `sector` (string, nullable, required) и `issuer` (nullable, required, `id`/`name` обязательные строки); версия OpenAPI 3.0.x; запросы, маршруты и статусы не изменились (diff схемы с `frontend/src/shared/api/spec/openapi.json` содержит только эти добавления)
|
||||
- **Depends on:** 1.6
|
||||
|
||||
- [x] 1.8 [backend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48
|
||||
- **Covers:** N/A — финальный gate repo
|
||||
- **Source:** backend `.ai/conventions/engineering.md` «Проверки и завершение»; `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт, в теле `WealthLedger/main#48` и OpenAPI-artifact приложен как вход для frontend
|
||||
- **Depends on:** 1.7
|
||||
|
||||
## 2. Frontend: контракт, модель, UI
|
||||
|
||||
- [x] 2.1 [frontend] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»
|
||||
- **Verification:** `git -C frontend branch --show-current` выводит `feature/48-account-top-positions`
|
||||
- **Depends on:** none
|
||||
|
||||
- [x] 2.2 [frontend] Implementation: предоставленный backend snapshot в `src/shared/api/spec/openapi.json` и generated `schema.d.ts`
|
||||
- **Covers:** `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** design.md B6; frontend `.ai/conventions/data.md` «Типы и contract»
|
||||
- **Verification:** `npm run openapi:generate`; `npm run openapi:check` без diff; `npm run typecheck`; generated `paths` содержит `sector` и `issuer` у позиции
|
||||
- **Depends on:** 1.7, 2.1
|
||||
|
||||
- [x] 2.3 [frontend] Implementation: `pages/account/model/concentration.ts` — состав разрезов, учитываемые позиции, валюты, точные суммы и доли, топ-5, «Прочее», порядок равных, имя эмитента, группы «не указан»
|
||||
- **Covers:** `portfolio-concentration` — `Состав разрезов`; `portfolio-concentration` — `Названия элементов`; `portfolio-concentration` — `Название эмитента при разных названиях бренда`; `portfolio-concentration` — `Позиции, учитываемые в разрезе`; `portfolio-concentration` — `Сноска о не учтённых позициях`; `portfolio-concentration` — `Одна валюта за раз`; `portfolio-concentration` — `Доступные валюты вкладки`; `portfolio-concentration` — `Топ-5 и строка «Прочее»`; `portfolio-concentration` — `Порядок элементов с равной суммой`; `portfolio-concentration` — `Расчёт долей`; `portfolio-concentration` — `Показ долей`; `portfolio-concentration` — `Суммарная доля топ-5`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
|
||||
- **Source:** design.md F1; analysis/data-mapping.md M3, I1–I8; analysis/test-cases/conc.md
|
||||
- **Verification:** `npm run test:run -- src/pages/account/model/concentration.test.ts` по oracle TC-CONC-007, TC-CONC-008, TC-CONC-016, TC-CONC-020; сумма точных долей = 100 %; 6,25 → «6,3 %»; «< 0,1 %»; FUND исключён из «Эмитентов»
|
||||
- **Depends on:** 2.2
|
||||
|
||||
- [x] 2.4 [frontend] Implementation: state `{accountId, tab, slice, currency}` в `AccountPage.vm.ts`, управляемые `Tabs`, вкладка только для одного счёта
|
||||
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-concentration` — `Вкладка в состояниях страницы`; `portfolio-concentration` — `Переключение разрезов`; `portfolio-concentration` — `Начальное состояние вкладки`; `portfolio-concentration` — `Независимый выбор валюты вкладки`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
|
||||
- **Source:** design.md F2, F3; analysis/test-cases/conc.md TC-CONC-002, TC-CONC-005, TC-CONC-006, TC-CONC-015
|
||||
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: вкладки нет для `all`, при загрузке, ошибке и пустом Portfolio; по умолчанию открыта «Позиции»; разрез и валюта сохраняются при переключении вкладок и сбрасываются при смене счёта; при исчезновении валюты выбирается первая
|
||||
- **Depends on:** 2.3
|
||||
|
||||
- [x] 2.5 [frontend] Implementation: `Concentration.view` — список, раскрытие «Прочее», пустое состояние, сноска, выбор валюты, Alert «Данные неполные», подавление страничного Alert на вкладке
|
||||
- **Covers:** `portfolio-concentration` — `Раскрытие строки «Прочее»`; `portfolio-concentration` — `Строки без переходов`; `portfolio-concentration` — `Пустое состояние разреза`; `portfolio-concentration` — `Предупреждение о неполных данных`
|
||||
- **Source:** design.md F2, F4, «UI Implementation»; analysis/ux-design.md «UI States», «Responsive Behavior»; visuals v2 (SHA-256 по ux-design «Materials» сверены до начала)
|
||||
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: при `partial` на «Концентрации» одно сообщение «Данные неполные», на других вкладках — прежний Alert; строки не являются ссылками; browser-тест 320 px по образцу `position-layout.browser.test.tsx` без горизонтального скролла страницы
|
||||
- **Depends on:** 2.4
|
||||
|
||||
- [x] 2.6 [frontend] Implementation: DonutChart — центр-оверлей, обводка и штриховка через `cellProps`, цифры ранга, текстовый эквивалент
|
||||
- **Covers:** `portfolio-concentration` — `Диаграмма распределения разреза`; `portfolio-concentration` — `Центр диаграммы`; `portfolio-concentration` — `Доступность диаграммы без цвета`
|
||||
- **Source:** design.md F5; analysis/ux-design.md «Accessibility», D7; analysis/test-cases/conc.md TC-CONC-027
|
||||
- **Verification:** `npm run test:run -- src/pages/account/ui/AccountPage`: 7 элементов → 6 сегментов в порядке списка; центр «Топ-5 · 95 %» и «1 000 ₽»; при ≤ 5 элементах только сумма; `role="img"` и `aria-describedby` с названиями, суммами и долями
|
||||
- **Depends on:** 2.5
|
||||
|
||||
- [x] 2.7 [frontend] Implementation: подписи секторов через общий словарь `stock.sectors` + ключ `government` («Государственный сектор»), видимый и в каталогах
|
||||
- **Covers:** `portfolio-concentration` — `Подписи секторов`; `portfolio-concentration` — `Русские подписи известных секторов`
|
||||
- **Source:** design.md F6; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084); analysis/test-cases/conc.md TC-CONC-024
|
||||
- **Verification:** `npm run test:run`: 11 кодов из таблицы PC дают русские подписи; `other` выводится как есть; тест `StockCompany` (страница акции): `sector: "government"` отображается как «Сектор: Государственный сектор» — owner-approved изменение вне Specs (design.md F6, [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084))
|
||||
- **Depends on:** 2.3
|
||||
|
||||
- [x] 2.8 [frontend] Automated Testing: регрессия вкладки «Позиции» и «Структуры портфеля»
|
||||
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`
|
||||
- **Source:** analysis/qa-analysis.md «Regression Impact»; analysis/test-cases/reg.md TC-REG-001
|
||||
- **Verification:** существующие `AccountPage.test.tsx`, `position-groups.test.tsx`, тесты `widgets/portfolio-structure` проходят без изменения ожиданий
|
||||
- **Depends on:** 2.6, 2.7
|
||||
|
||||
- [x] 2.9 [frontend] Verification: полный gate и PR в `develop` со ссылкой на WealthLedger/main#48 и backend PR
|
||||
- **Covers:** N/A — финальный gate repo
|
||||
- **Source:** frontend `.ai/conventions/engineering.md`; `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||
- **Verification:** `npm run verify` зелёный на финальной ревизии; PR открыт со ссылками на `WealthLedger/main#48` и backend PR
|
||||
- **Depends on:** 2.8
|
||||
|
||||
## 3. Review и QA до G4
|
||||
|
||||
- [x] 3.1 ~~[specs] Independent Review: read-only review backend и frontend PR против Specs, design и analysis~~ (descoped, см. process-record)
|
||||
- **Covers:** `portfolio-view` — `Результат получения сектора и эмитента`; `portfolio-concentration` — `Расчёт долей`
|
||||
- **Source:** design.md целиком; analysis/system-analysis.md; analysis/failure-model.md
|
||||
- **Verification:** отчёт review приложен к Gate Log G4 в process-record; blocker/major закрыты или возвращены в upstream-артефакт
|
||||
- **Depends on:** 1.8, 2.9
|
||||
|
||||
- [x] 3.2 ~~[specs] QA Verification: прогон test-cases на локальном backend + frontend~~ (descoped, см. process-record)
|
||||
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-concentration` — `Состояния Portfolio на вкладке «Концентрация»`
|
||||
- **Source:** analysis/qa-analysis.md «Coverage Matrix», «Entry / Exit Criteria»; analysis/test-cases/conc.md, meta.md, reg.md
|
||||
- **Verification:** результаты TC-CONC-002/005/006/007/008/015/016/020/024/027, TC-META-002/003/004/005, TC-REG-001/004 записаны pass/fail; F01–F16 покрыты; ручной блок (макеты v2, контраст, клавиатура, скринридер) зафиксирован
|
||||
- **Depends on:** 1.8, 2.9
|
||||
|
||||
- [x] 3.3 ~~[specs] QA Verification: exploratory Q-D1/Q-D2/Q-F3 на реальном токене владельца~~ (descoped, см. process-record)
|
||||
- **Covers:** `portfolio-view` — `Обновление сектора и эмитента`; `portfolio-concentration` — `Группы «Сектор не указан» и «Эмитент не указан»`
|
||||
- **Source:** analysis/qa-analysis.md «Gaps / Blockers», «Test Data and Environment Needs»; решение владельца [3084](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3084)
|
||||
- **Verification:** записаны полнота сектора и бренда у позиций владельца (доля стоимости без значения, наличие `government` и фондов без сектора), число RPC и длительность первого обхода, отсутствие RESOURCE_EXHAUSTED / queue full; шаг Portfolio укладывается в `WORK_WAIT_BOUND_MS`
|
||||
- **Depends on:** 3.2
|
||||
|
||||
- [x] 3.4 ~~[backend] Verification: откат backend-bundle на копии данных до G4~~ (descoped, см. process-record)
|
||||
- **Covers:** N/A — проверка отката (design.md «Migration / Rollout / Rollback»)
|
||||
- **Source:** design.md «Migration / Rollout / Rollback» (Backend); `deploy/README.md` «Ручной откат и восстановление»; `docs/architecture/deployment.md` «Откат image»
|
||||
- **Verification:** на disposable копии БД с применённой миграцией `TbankInstrumentClassification`: (1) предыдущий backend-image, запущенный как при ручном откате (`up --no-deps backend`, без сервиса `migration`), проходит healthcheck, GET Portfolio отвечает `200` на Snapshot с полями `sector`/`issuer`; (2) из предыдущего image `node node_modules/prisma/build/index.js migrate status` и `migrate deploy` — записаны exit code и вывод (ожидание: status сообщает о миграции, отсутствующей локально; deploy не падает). Результат записан в Gate Log G4; при падении deploy — finding в design
|
||||
- **Depends on:** 1.8
|
||||
|
||||
## 4. Docs (по analysis/docs-impact.md, после merge кода в `develop`)
|
||||
|
||||
- [x] 4.1 [docs] Verification: ветка `feature/48-account-top-positions` от актуального `develop`
|
||||
- **Covers:** N/A — организационный шаг cross-repo delivery
|
||||
- **Source:** `.ai/conventions/engineering-workflow.md` «Analysis → Branch»; analysis/docs-impact.md «Summary» (правки AS-IS только после merge кода)
|
||||
- **Verification:** backend и frontend PR слиты в `develop`; `git -C docs branch --show-current` выводит `feature/48-account-top-positions`
|
||||
- **Depends on:** 1.8, 2.9, 3.3
|
||||
|
||||
- [x] 4.2 [docs] Docs: домен Portfolio — `overview.md`, при необходимости `concentration.md`, `data-reference.md`, `flows/portfolio-loading.md`, условная строка в `all-accounts.md`
|
||||
- **Covers:** `portfolio-concentration` — `Вкладка «Концентрация» страницы счёта`; `portfolio-view` — `Нормализованный состав Portfolio`
|
||||
- **Source:** analysis/docs-impact.md «Documentation Changes» (строки домена и flow)
|
||||
- **Verification:** `npm run verify` в docs; текст соответствует реализованному AS-IS
|
||||
- **Depends on:** 4.1
|
||||
|
||||
- [x] 4.3 [docs] Docs: интеграция и архитектура — `integrations/tbank.md`, `architecture/cross-cutting/data-freshness.md`, `architecture/containers.md`
|
||||
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`; `portfolio-view` — `Обновление сектора и эмитента`
|
||||
- **Source:** analysis/docs-impact.md «Documentation Changes» (Integration, Architecture); design.md B1, B4
|
||||
- **Verification:** `npm run verify` в docs
|
||||
- **Depends on:** 4.1
|
||||
|
||||
- [x] 4.4 [docs] Docs: ADR 022 `stored-instrument-classification` (Accepted, AS-IS), строка в `adr/README.md`, ссылка в Status ADR 019
|
||||
- **Covers:** `portfolio-view` — `Свежесть и fallback полного Portfolio Snapshot`
|
||||
- **Source:** analysis/docs-impact.md «ADR Decision»; design.md «ADR Candidates», «Solution Assessment»; решение владельца [3083](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3083)
|
||||
- **Verification:** `npm run verify` в docs; текст ADR 019 кроме строки Status не изменён
|
||||
- **Depends on:** 4.1
|
||||
|
||||
- [x] 4.5 [docs] Docs: глоссарий и навигация — `glossary.md`, `overview.md`, `README.md`
|
||||
- **Covers:** `portfolio-concentration` — `Переключение разрезов`
|
||||
- **Source:** analysis/docs-impact.md «Glossary / Navigation Impact»
|
||||
- **Verification:** `npm run verify` в docs; ссылки из навигации ведут на домен Portfolio
|
||||
- **Depends on:** 4.2
|
||||
|
||||
- [x] 4.6 [docs] Verification: PR в `develop` со ссылкой на WealthLedger/main#48 и code PR
|
||||
- **Covers:** N/A — финальный gate repo
|
||||
- **Source:** `.ai/conventions/engineering-workflow.md` «Validation → PR»
|
||||
- **Verification:** `npm run verify` зелёный; PR открыт со ссылками на `WealthLedger/main#48`, backend и frontend PR
|
||||
- **Depends on:** 4.2, 4.3, 4.4, 4.5
|
||||
|
||||
## 5. Specs: G4 и завершение change
|
||||
|
||||
- [x] 5.1 ~~[specs] Verification: запись G4 в process-record с evidence задач 1–4~~ (descoped, см. process-record)
|
||||
- **Covers:** N/A — gate протокол
|
||||
- **Source:** instruction `process-record` (Readiness Checklist, Gate Log)
|
||||
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 validate account-top-positions --store wealth-ledger-specs --strict` valid; `git diff --check` чист; строка G4 содержит ссылки на PR, отчёты 3.1–3.3 и решение владельца
|
||||
- **Depends on:** 3.1, 3.2, 3.3, 3.4, 4.6
|
||||
|
||||
- [x] 5.2 [specs] Verification: native archive change после G4 и PR в `develop` со ссылкой на WealthLedger/main#48
|
||||
- **Covers:** N/A — native sync/archive выполняет CLI, canonical specs вручную не правятся
|
||||
- **Source:** schema `wealth-ledger-sdd-lab` apply/archive instruction; `.ai/conventions/engineering-workflow.md` «Merge → Issue Closed»
|
||||
- **Verification:** `npx -y @fission-ai/openspec@1.13.1 archive account-top-positions --store wealth-ledger-specs` завершён; `openspec validate --all --strict` valid; PR specs открыт; Issue main#48 закрывается после merge всех PR
|
||||
- **Depends on:** 5.1
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800" role="img">
|
||||
<title>Концентрация — desktop — разрез Эмитенты, одна валюта, предупреждение</title>
|
||||
<desc>Макет вкладки Концентрация, разрез Эмитенты, валюта RUB. Центр: Топ-5 · 94,2 %, 3 527 000 ₽. Сбер 31,8 %; Газпром 22,1 %; ЛУКОЙЛ 18,1 %; Эмитент не указан 11,6 %; Яндекс 10,5 %; РЖД 5,8 %; предупреждение «Данные неполные»</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1280" height="800" rx="0" fill="#e9ecef" />
|
||||
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||
<rect x="120" y="120" width="1040" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="120" y="120" width="4" height="48" rx="2" fill="#2563eb" /><text x="140" y="149" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
|
||||
<rect x="120" y="184" width="1040" height="568" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="144" y="208" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<text x="204.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||
<text x="317.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||
<text x="430.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||
<rect x="487.0" y="212" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="543.5" y="233" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
|
||||
<rect x="144" y="272" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 330.00 A110 110 0 0 1 392.24 485.30 L363.08 472.12 A78 78 0 0 0 292.00 362.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="396.2" y="376.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||
<path d="M392.24 485.30 A110 110 0 0 1 265.51 546.76 L273.22 515.71 A78 78 0 0 0 363.08 472.12 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="346.1" y="555.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||
<path d="M265.51 546.76 A110 110 0 0 1 183.93 460.50 L215.37 454.54 A78 78 0 0 0 273.22 515.71 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="201.9" y="529.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<path d="M183.93 460.50 A110 110 0 0 1 197.82 383.17 L225.22 399.70 A78 78 0 0 0 215.37 454.54 Z" fill="url(#hatch)"/>
|
||||
<text x="170.0" y="422.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||
<path d="M197.82 383.17 A110 110 0 0 1 252.72 337.25 L264.14 367.14 A78 78 0 0 0 225.22 399.70 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="212.4" y="348.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||
<path d="M252.72 337.25 A110 110 0 0 1 292.00 330.00 L292.00 362.00 A78 78 0 0 0 264.14 367.14 Z" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="269.5" y="322.1" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">6</text>
|
||||
<text x="292.0" y="434" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 94,2 %</text>
|
||||
<text x="292.0" y="462" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 527 000 ₽</text></g>
|
||||
<text x="292.0" y="602" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
|
||||
<text x="476" y="305" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||
<circle cx="506" cy="300" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбер</text>
|
||||
<text x="1136" y="296" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
|
||||
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">31,8 %</text>
|
||||
<rect x="528" y="326" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="326" width="193.07059824213212" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="346" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="381" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||
<circle cx="506" cy="376" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
|
||||
<text x="1136" y="372" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
|
||||
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,1 %</text>
|
||||
<rect x="528" y="402" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="402" width="134.45988091862773" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="422" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="457" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||
<circle cx="506" cy="452" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
|
||||
<text x="1136" y="448" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
|
||||
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">18,1 %</text>
|
||||
<rect x="528" y="478" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="478" width="110.32605613836121" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="498" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="533" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||
<circle cx="506" cy="528" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="528" r="5" fill="url(#hatch)"/>
|
||||
<text x="528" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Эмитент не указан</text>
|
||||
<text x="1136" y="524" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
|
||||
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,6 %</text>
|
||||
<rect x="528" y="554" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="554" width="70.67762971363764" height="6" rx="3" fill="url(#hatch)" />
|
||||
<rect x="528" y="574" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="609" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||
<circle cx="506" cy="604" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
|
||||
<text x="1136" y="600" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
|
||||
<text x="1136" y="620" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">10,5 %</text>
|
||||
<rect x="528" y="630" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="630" width="64.12702013042247" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="650" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="685" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">6</text>
|
||||
<circle cx="506" cy="680" r="5" fill="#eff6ff" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">РЖД</text>
|
||||
<text x="1136" y="676" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">205 000 ₽</text>
|
||||
<text x="1136" y="696" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">5,8 %</text>
|
||||
<rect x="528" y="706" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="706" width="35.33881485681882" height="6" rx="3" fill="#2563eb" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
+116
@@ -0,0 +1,116 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="932" viewBox="0 0 1280 932" role="img">
|
||||
<title>Концентрация — desktop — разрез Секторы, «Прочее» раскрыто, фокус на «Прочее»</title>
|
||||
<desc>Макет вкладки Концентрация, разрез Секторы, валюта RUB. Центр: Топ-5 · 92,1 %, 4 079 330 ₽. Финансы 29,5 %; Государственный сектор 24,2 %; Энергетика 20,7 %; Информационные технологии 9,9 %; Сектор не указан 7,8 %; Прочее 7,9 %</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1280" height="932" rx="0" fill="#e9ecef" />
|
||||
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||
<rect x="120" y="128" width="1040" height="756" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||
<rect x="374.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Секторы</text>
|
||||
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
|
||||
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 397.58 414.87 L366.87 405.89 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="391.2" y="313.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||
<path d="M397.58 414.87 A110 110 0 0 1 266.62 491.03 L274.01 459.90 A78 78 0 0 0 366.87 405.89 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="354.3" y="495.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||
<path d="M266.62 491.03 A110 110 0 0 1 182.07 387.90 L214.05 386.76 A78 78 0 0 0 274.01 459.90 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="196.1" y="466.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||
<path d="M182.07 387.90 A110 110 0 0 1 200.23 323.36 L226.92 341.00 A78 78 0 0 0 214.05 386.76 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="172.6" y="354.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<path d="M200.23 323.36 A110 110 0 0 1 239.63 287.26 L254.87 315.41 A78 78 0 0 0 226.92 341.00 Z" fill="url(#hatch)"/>
|
||||
<text x="208.2" y="296.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||
<path d="M239.63 287.26 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 254.87 315.41 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 92,1 %</text>
|
||||
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">4 079 330 ₽</text></g>
|
||||
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Секторы · RUB</text>
|
||||
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Финансы</text>
|
||||
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">1 204 500 ₽</text>
|
||||
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">29,5 %</text>
|
||||
<rect x="528" y="270" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="270" width="179.52359823794592" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Государственный сектор</text>
|
||||
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">986 300 ₽</text>
|
||||
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">24,2 %</text>
|
||||
<rect x="528" y="346" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="346" width="147.0021792794405" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Энергетика</text>
|
||||
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">845 700 ₽</text>
|
||||
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">20,7 %</text>
|
||||
<rect x="528" y="422" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="422" width="126.046581178772" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="528" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Информационные технологии</text>
|
||||
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">402 100 ₽</text>
|
||||
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">9,9 %</text>
|
||||
<rect x="528" y="498" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="498" width="59.93062586258038" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="506" cy="548" r="5" fill="url(#hatch)"/>
|
||||
<text x="528" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сектор не указан</text>
|
||||
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">318 600 ₽</text>
|
||||
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,8 %</text>
|
||||
<rect x="528" y="574" width="608" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="574" width="47.48544491374809" height="6" rx="3" fill="url(#hatch)" />
|
||||
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<rect x="464" y="596" width="680" height="72" rx="10" fill="#eff6ff" />
|
||||
<rect x="462" y="594" width="684" height="76" rx="12" fill="none" stroke="#2563eb" stroke-width="2" />
|
||||
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
|
||||
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">322 130 ₽</text>
|
||||
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">7,9 %</text>
|
||||
<path d="M1116 626 l6 -6 l6 6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="650" width="45.32671296511927" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
<rect x="506" y="666" width="2" height="176" rx="1" fill="#dee2e6" />
|
||||
<text x="528" y="694" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">6</text>
|
||||
<text x="552" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Потребительский сектор</text>
|
||||
<text x="1102" y="694" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">164 200 ₽</text>
|
||||
<text x="1102" y="712" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">4 %</text>
|
||||
<rect x="552" y="720" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||
<rect x="552" y="720" width="22.138439400587842" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
<text x="528" y="754" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">7</text>
|
||||
<text x="552" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Телекоммуникации</text>
|
||||
<text x="1102" y="754" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">96 410 ₽</text>
|
||||
<text x="1102" y="772" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">2,4 %</text>
|
||||
<rect x="552" y="780" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||
<rect x="552" y="780" width="12.998580649273288" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
<text x="528" y="814" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="start">8</text>
|
||||
<text x="552" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Материалы</text>
|
||||
<text x="1102" y="814" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="end">61 520 ₽</text>
|
||||
<text x="1102" y="832" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">1,5 %</text>
|
||||
<rect x="552" y="840" width="550" height="4" rx="3" fill="#f1f3f5" />
|
||||
<rect x="552" y="840" width="8.294499341803679" height="4" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,104 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="780" viewBox="0 0 1280 780" role="img">
|
||||
<title>Концентрация — desktop — разрез Акции</title>
|
||||
<desc>Макет вкладки Концентрация, разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1280" height="780" rx="0" fill="#e9ecef" />
|
||||
<text x="120" y="40" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Брокерский счёт · Основной</text>
|
||||
<rect x="120" y="103" width="1040" height="1" rx="0" fill="#dee2e6" />
|
||||
<text x="175.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="230" y="101" width="150" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="305.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="430.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="535.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
<text x="645.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">События</text>
|
||||
<text x="745.0" y="84" font-family="system-ui, sans-serif" font-size="15" font-weight="500" fill="#495057" text-anchor="middle">Доход</text>
|
||||
<rect x="120" y="128" width="1040" height="604" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="144" y="152" width="460" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<rect x="148.0" y="156" width="113.0" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="204.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
|
||||
<text x="317.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||
<text x="430.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||
<text x="543.5" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||
<rect x="1024" y="152" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="1038" y="177" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M1112 169 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="1014" y="177" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">Валюта</text>
|
||||
<rect x="144" y="216" width="296" height="380" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||
<g role="img" aria-label="Диаграмма разреза"><path d="M292.00 274.00 A110 110 0 0 1 400.75 400.53 L369.11 395.72 A78 78 0 0 0 292.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="386.0" y="307.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||
<path d="M400.75 400.53 A110 110 0 0 1 294.14 493.98 L293.52 461.99 A78 78 0 0 0 369.11 395.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="373.7" y="481.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||
<path d="M294.14 493.98 A110 110 0 0 1 197.93 441.02 L225.30 424.43 A78 78 0 0 0 293.52 461.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="232.2" y="496.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||
<path d="M197.93 441.02 A110 110 0 0 1 184.15 362.36 L215.52 368.66 A78 78 0 0 0 225.30 424.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="169.9" y="409.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||
<path d="M184.15 362.36 A110 110 0 0 1 209.70 311.02 L233.64 332.25 A78 78 0 0 0 215.52 368.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="181.0" y="332.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||
<path d="M209.70 311.02 A110 110 0 0 1 292.00 274.00 L292.00 306.00 A78 78 0 0 0 233.64 332.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<text x="292.0" y="378" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
|
||||
<text x="292.0" y="406" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
|
||||
<text x="292.0" y="546" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
|
||||
<text x="476" y="249" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">1</text>
|
||||
<circle cx="506" cy="244" r="5" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<rect x="528" y="224" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="548.0" y="249.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
|
||||
<text x="580" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Сбербанк</text>
|
||||
<text x="580" y="260" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
|
||||
<text x="1136" y="240" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
|
||||
<text x="1136" y="260" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
|
||||
<rect x="580" y="270" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="580" y="270" width="152.34525859947837" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="290" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="325" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">2</text>
|
||||
<circle cx="506" cy="320" r="5" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<rect x="528" y="300" width="40" height="40" rx="8" fill="#fff4e6" /><text x="548.0" y="325.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
|
||||
<text x="580" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">ЛУКОЙЛ</text>
|
||||
<text x="580" y="336" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
|
||||
<text x="1136" y="316" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
|
||||
<text x="1136" y="336" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
|
||||
<rect x="580" y="346" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="580" y="346" width="123.93225100769021" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="366" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="401" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">3</text>
|
||||
<circle cx="506" cy="396" r="5" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<rect x="528" y="376" width="40" height="40" rx="8" fill="#ebfbee" /><text x="548.0" y="401.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
|
||||
<text x="580" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Яндекс</text>
|
||||
<text x="580" y="412" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
|
||||
<text x="1136" y="392" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
|
||||
<text x="1136" y="412" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
|
||||
<rect x="580" y="422" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="580" y="422" width="92.50457430960358" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="442" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="477" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">4</text>
|
||||
<circle cx="506" cy="472" r="5" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<rect x="528" y="452" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="548.0" y="477.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
|
||||
<text x="580" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Газпром</text>
|
||||
<text x="580" y="488" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
|
||||
<text x="1136" y="468" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
|
||||
<text x="1136" y="488" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
|
||||
<rect x="580" y="498" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="580" y="498" width="65.7406802635876" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="518" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="553" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start">5</text>
|
||||
<circle cx="506" cy="548" r="5" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<rect x="528" y="528" width="40" height="40" rx="8" fill="#fff3bf" /><text x="548.0" y="553.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
|
||||
<text x="580" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Норникель</text>
|
||||
<text x="580" y="564" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
|
||||
<text x="1136" y="544" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
|
||||
<text x="1136" y="564" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
|
||||
<rect x="580" y="574" width="556" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="580" y="574" width="46.675136781922866" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="528" y="594" width="608" height="1" rx="0" fill="#f1f3f5" />
|
||||
<text x="476" y="629" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="start"></text>
|
||||
<circle cx="506" cy="624" r="5" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||
<text x="528" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||
<text x="528" y="640" font-family="system-ui, sans-serif" font-size="13" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||
<text x="1102" y="620" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
|
||||
<text x="1102" y="640" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
|
||||
<path d="M1116 620 l6 6 l6 -6" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="528" y="650" width="574" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="528" y="650" width="77.22374972598878" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
<text x="476" y="700" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
+37
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="474" viewBox="0 0 390 474" role="img">
|
||||
<title>Концентрация — mobile — пустой разрез Облигации</title>
|
||||
<desc>Макет вкладки Концентрация (mobile 390), разрез Облигации, валюта RUB. пустое состояние</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="390" height="474" rx="0" fill="#e9ecef" />
|
||||
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||
<g clip-path="url(#tabclip)">
|
||||
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
</g>
|
||||
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||
<rect x="16" y="116" width="358" height="326" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||
<rect x="113.5" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Облигации</text>
|
||||
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
|
||||
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="28" y="240" width="334" height="130" rx="12" fill="#f8f9fa" stroke="#dee2e6" stroke-width="1" />
|
||||
<text x="44" y="276" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#242424" text-anchor="start">Облигации в RUB: нет позиций</text>
|
||||
<text x="44" y="304" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">Нет позиций с положительной оценкой</text>
|
||||
<text x="44" y="324" font-family="system-ui, sans-serif" font-size="14" font-weight="400" fill="#495057" text-anchor="start">в этом разрезе и выбранной валюте.</text>
|
||||
<text x="32" y="404" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 1</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
+80
@@ -0,0 +1,80 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1020" viewBox="0 0 390 1020" role="img">
|
||||
<title>Концентрация — mobile — разрез Эмитенты, пять элементов, предупреждение</title>
|
||||
<desc>Макет вкладки Концентрация (mobile 390), разрез Эмитенты, валюта RUB. Центр: 3 322 000 ₽. Сбер 33,7 %; Газпром 23,5 %; ЛУКОЙЛ 19,3 %; Эмитент не указан 12,3 %; Яндекс 11,2 %; предупреждение «Данные неполные»</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="390" height="1020" rx="0" fill="#e9ecef" />
|
||||
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||
<g clip-path="url(#tabclip)">
|
||||
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
</g>
|
||||
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||
<rect x="16" y="112" width="358" height="48" rx="8" fill="#eff6ff" stroke="#bfdbfe" stroke-width="1" /><rect x="16" y="112" width="4" height="48" rx="2" fill="#2563eb" /><text x="36" y="141" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="start">Данные неполные</text>
|
||||
<rect x="16" y="172" width="358" height="816" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="28" y="188" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<text x="72.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Акции</text>
|
||||
<text x="154.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||
<text x="235.75" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||
<rect x="276.5" y="192" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="317.25" y="213" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Эмитенты</text>
|
||||
<rect x="28" y="244" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 274.00 A110 110 0 0 1 288.92 441.27 L261.60 424.61 A78 78 0 0 0 195.00 306.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="303.1" y="327.3" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||
<path d="M288.92 441.27 A110 110 0 0 1 146.95 482.95 L160.93 454.17 A78 78 0 0 0 261.60 424.61 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="229.9" y="507.0" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||
<path d="M146.95 482.95 A110 110 0 0 1 85.46 373.92 L117.33 376.85 A78 78 0 0 0 160.93 454.17 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="87.0" y="448.9" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<path d="M85.46 373.92 A110 110 0 0 1 123.83 300.12 L144.54 324.52 A78 78 0 0 0 117.33 376.85 Z" fill="url(#hatch)"/>
|
||||
<text x="85.0" y="330.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||
<path d="M123.83 300.12 A110 110 0 0 1 195.00 274.00 L195.00 306.00 A78 78 0 0 0 144.54 324.52 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="152.3" y="271.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||
<text x="195.0" y="392" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">3 322 000 ₽</text></g>
|
||||
<text x="195.0" y="532" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Эмитенты · RUB</text>
|
||||
<circle cx="38" cy="578" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="54" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбер</text>
|
||||
<text x="358" y="580" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">1 120 000 ₽</text>
|
||||
<text x="358" y="598" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">33,7 %</text>
|
||||
<rect x="54" y="610" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="610" width="102.4924744130042" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="54" y="632" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||
<circle cx="38" cy="662" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="54" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. Газпром</text>
|
||||
<text x="358" y="664" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">780 000 ₽</text>
|
||||
<text x="358" y="682" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">23,5 %</text>
|
||||
<rect x="54" y="694" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="694" width="71.37868753762794" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="54" y="716" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||
<circle cx="38" cy="746" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="54" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. ЛУКОЙЛ</text>
|
||||
<text x="358" y="748" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">640 000 ₽</text>
|
||||
<text x="358" y="766" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">19,3 %</text>
|
||||
<rect x="54" y="778" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="778" width="58.56712823600241" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="54" y="800" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||
<circle cx="38" cy="830" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/><circle cx="38" cy="830" r="6" fill="url(#hatch)"/>
|
||||
<text x="54" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Эмитент не указан</text>
|
||||
<text x="358" y="832" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">410 000 ₽</text>
|
||||
<text x="358" y="850" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">12,3 %</text>
|
||||
<rect x="54" y="862" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="54" y="862" width="37.519566526189045" height="6" rx="3" fill="url(#hatch)" />
|
||||
<rect x="54" y="884" width="304" height="1" rx="0" fill="#f1f3f5" />
|
||||
<circle cx="38" cy="914" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="54" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Яндекс</text>
|
||||
<text x="358" y="916" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">372 000 ₽</text>
|
||||
<text x="358" y="934" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,2 %</text>
|
||||
<rect x="54" y="946" width="304" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="946" width="34.0421432871764" height="6" rx="3" fill="#2563eb" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,101 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="1144" viewBox="0 0 390 1144" role="img">
|
||||
<title>Концентрация — mobile — разрез Акции</title>
|
||||
<desc>Макет вкладки Концентрация (mobile 390), разрез Акции, валюта RUB. Центр: Топ-5 · 86,5 %, 2 235 310 ₽. Сбербанк 27,4 %; ЛУКОЙЛ 22,3 %; Яндекс 16,6 %; Газпром 11,8 %; Норникель 8,4 %; Прочее 13,5 %</desc>
|
||||
<defs>
|
||||
<pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#dee2e6"/><circle cx="1.5" cy="1.5" r="1" fill="#868e96"/><circle cx="4.5" cy="4.5" r="1" fill="#868e96"/></pattern>
|
||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2.5" height="6" fill="#ffffff" fill-opacity="0.75"/></pattern>
|
||||
<linearGradient id="hero" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eff6ff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
|
||||
<filter id="sh" x="-5%" y="-5%" width="110%" height="115%"><feDropShadow dx="0" dy="1" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/></filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="390" height="1144" rx="0" fill="#e9ecef" />
|
||||
<text x="16" y="34" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#242424" text-anchor="start">Основной</text>
|
||||
<clipPath id="tabclip"><rect x="0" y="52" width="390" height="48"/></clipPath>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#e9ecef" stop-opacity="0"/><stop offset="1" stop-color="#e9ecef" stop-opacity="1"/></linearGradient>
|
||||
<rect x="0" y="99" width="390" height="1" rx="0" fill="#dee2e6" />
|
||||
<g clip-path="url(#tabclip)">
|
||||
<text x="55.5" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Позиции</text>
|
||||
<rect x="95" y="96" width="118" height="3" rx="0" fill="#2563eb" />
|
||||
<text x="154" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#242424" text-anchor="middle">Концентрация</text>
|
||||
<text x="262" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Деньги</text>
|
||||
<text x="365" y="82" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#495057" text-anchor="middle">Операции</text>
|
||||
</g>
|
||||
<rect x="350" y="52" width="40" height="47" rx="0" fill="url(#fade)" />
|
||||
<rect x="16" y="116" width="358" height="996" rx="16" fill="#fff" stroke="#dee2e6" stroke-width="1" filter="url(#sh)"/>
|
||||
<rect x="28" y="132" width="334" height="40" rx="8" fill="#f1f3f5" stroke="#dee2e6" stroke-width="1" />
|
||||
<rect x="32.0" y="136" width="81.5" height="32" rx="6" fill="#ffffff" stroke="#ced4da" stroke-width="1" filter="url(#sh)"/>
|
||||
<text x="72.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#242424" text-anchor="middle">Акции</text>
|
||||
<text x="154.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Облигации</text>
|
||||
<text x="235.75" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Секторы</text>
|
||||
<text x="317.25" y="157" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="middle">Эмитенты</text>
|
||||
<text x="32" y="209" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Валюта</text>
|
||||
<rect x="92" y="184" width="112" height="40" rx="8" fill="#fff" stroke="#ced4da" stroke-width="1" /><text x="106" y="209" font-family="system-ui, sans-serif" font-size="14" font-weight="500" fill="#242424" text-anchor="start">RUB</text><path d="M180 201 l5 5 l5 -5" fill="none" stroke="#495057" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="28" y="240" width="334" height="304" rx="16" fill="url(#hero)" stroke="#dbeafe" stroke-width="1" />
|
||||
<g role="img" aria-label="Диаграмма разреза"><path d="M195.00 270.00 A110 110 0 0 1 303.75 396.53 L272.11 391.72 A78 78 0 0 0 195.00 302.00 Z" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="289.0" y="303.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">1</text>
|
||||
<path d="M303.75 396.53 A110 110 0 0 1 197.14 489.98 L196.52 457.99 A78 78 0 0 0 272.11 391.72 Z" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="276.7" y="477.2" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">2</text>
|
||||
<path d="M197.14 489.98 A110 110 0 0 1 100.93 437.02 L128.30 420.43 A78 78 0 0 0 196.52 457.99 Z" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="135.2" y="492.6" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">3</text>
|
||||
<path d="M100.93 437.02 A110 110 0 0 1 87.15 358.36 L118.52 364.66 A78 78 0 0 0 128.30 420.43 Z" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="72.9" y="405.4" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">4</text>
|
||||
<path d="M87.15 358.36 A110 110 0 0 1 112.70 307.02 L136.64 328.25 A78 78 0 0 0 118.52 364.66 Z" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<text x="84.0" y="328.8" font-family="system-ui, sans-serif" font-size="12" font-weight="700" fill="#242424" text-anchor="middle">5</text>
|
||||
<path d="M112.70 307.02 A110 110 0 0 1 195.00 270.00 L195.00 302.00 A78 78 0 0 0 136.64 328.25 Z" fill="url(#dots)" stroke="#868e96" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<text x="195.0" y="374" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#495057" text-anchor="middle">Топ-5 · 86,5 %</text>
|
||||
<text x="195.0" y="402" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#242424" text-anchor="middle">2 235 310 ₽</text></g>
|
||||
<text x="195.0" y="528" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#495057" text-anchor="middle">Акции · RUB</text>
|
||||
<rect x="32" y="560" width="40" height="40" rx="8" fill="#e7f5ff" /><text x="52.0" y="585.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">С</text>
|
||||
<circle cx="70" cy="598" r="6" fill="#1e3a8a" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="84" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">1. Сбербанк</text>
|
||||
<text x="84" y="594" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">SBER</text>
|
||||
<text x="358" y="576" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">612 480 ₽</text>
|
||||
<text x="358" y="594" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">27,4 %</text>
|
||||
<rect x="84" y="606" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="84" y="606" width="75.07662024506668" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="84" y="628" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||
<rect x="32" y="644" width="40" height="40" rx="8" fill="#fff4e6" /><text x="52.0" y="669.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Л</text>
|
||||
<circle cx="70" cy="682" r="6" fill="#1d4ed8" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="84" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">2. ЛУКОЙЛ</text>
|
||||
<text x="84" y="678" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">LKOH</text>
|
||||
<text x="358" y="660" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">498 250 ₽</text>
|
||||
<text x="358" y="678" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">22,3 %</text>
|
||||
<rect x="84" y="690" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="84" y="690" width="61.07452657573223" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="84" y="712" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||
<rect x="32" y="728" width="40" height="40" rx="8" fill="#ebfbee" /><text x="52.0" y="753.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Я</text>
|
||||
<circle cx="70" cy="766" r="6" fill="#3b82f6" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="84" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">3. Яндекс</text>
|
||||
<text x="84" y="762" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">YDEX</text>
|
||||
<text x="358" y="744" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">371 900 ₽</text>
|
||||
<text x="358" y="762" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">16,6 %</text>
|
||||
<rect x="84" y="774" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="84" y="774" width="45.58678662020033" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="84" y="796" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||
<rect x="32" y="812" width="40" height="40" rx="8" fill="#f3d9fa" /><text x="52.0" y="837.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Г</text>
|
||||
<circle cx="70" cy="850" r="6" fill="#93c5fd" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="84" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">4. Газпром</text>
|
||||
<text x="84" y="846" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GAZP</text>
|
||||
<text x="358" y="828" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">264 300 ₽</text>
|
||||
<text x="358" y="846" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">11,8 %</text>
|
||||
<rect x="84" y="858" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="84" y="858" width="32.39738559752339" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="84" y="880" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||
<rect x="32" y="896" width="40" height="40" rx="8" fill="#fff3bf" /><text x="52.0" y="921.0" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#495057" text-anchor="middle">Н</text>
|
||||
<circle cx="70" cy="934" r="6" fill="#dbeafe" stroke="#1e3a8a" stroke-width="1.2"/>
|
||||
<text x="84" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">5. Норникель</text>
|
||||
<text x="84" y="930" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">GMKN</text>
|
||||
<text x="358" y="912" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">187 650 ₽</text>
|
||||
<text x="358" y="930" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">8,4 %</text>
|
||||
<rect x="84" y="942" width="274" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="84" y="942" width="23.00177604001235" height="6" rx="3" fill="#2563eb" />
|
||||
<rect x="84" y="964" width="274" height="1" rx="0" fill="#f1f3f5" />
|
||||
<circle cx="38" cy="994" r="6" fill="url(#dots)" stroke="#868e96" stroke-width="1.5"/>
|
||||
<text x="54" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="600" fill="#242424" text-anchor="start">Прочее</text>
|
||||
<text x="54" y="1014" font-family="system-ui, sans-serif" font-size="12" font-weight="400" fill="#495057" text-anchor="start">3 элемента</text>
|
||||
<text x="336" y="996" font-family="system-ui, sans-serif" font-size="15" font-weight="700" fill="#242424" text-anchor="end">300 730 ₽</text>
|
||||
<text x="336" y="1014" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="end">13,5 %</text>
|
||||
<path d="M345 988 l5 5 l5 -5" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="54" y="1026" width="282" height="6" rx="3" fill="#f1f3f5" />
|
||||
<rect x="54" y="1026" width="37.93919411625233" height="6" rx="3" fill="#868e96" stroke="#495057" stroke-width="1" />
|
||||
<text x="32" y="1082" font-family="system-ui, sans-serif" font-size="13" font-weight="500" fill="#495057" text-anchor="start">Не учтено позиций: 2</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
Reference in New Issue
Block a user