spec: record account-top-positions planning and implementation (WealthLedger/main#48) (#69)
This commit was merged in pull request #69.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# Маппинг данных
|
||||
|
||||
## Источник и цель
|
||||
|
||||
Документ не выбирает внутренний способ загрузки и хранения. Обозначения **PC**, **PVΔ** и правило нумерации строк — в [system-analysis.md](system-analysis.md). Requirements: `specs/openspec/changes/account-top-positions/specs/portfolio-concentration/spec.md` (28) и `specs/openspec/changes/account-top-positions/specs/portfolio-view/spec.md` (4). Решения владельца: [3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082). Исходы получения (когда поле заменяется, сохраняется или становится `null`, когда отсчёт 24 часов начинается заново) определяет таблица PVΔ:156; здесь она не пересказывается.
|
||||
|
||||
| Mapping | Источник (владелец) | Цель (владелец) | Направление | Trigger |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M1: сектор | `sector` Share / Bond / Etf (T-Bank) | `positions[].sector` (backend) | T-Bank → хранимое значение → HTTP Portfolio | Часовой обход по условию PVΔ:141 |
|
||||
| M2: эмитент | `asset_uid` инструмента → `AssetFull.brand.uid` / `name` (T-Bank) | `positions[].issuer` (backend) | T-Bank → хранимое значение → HTTP Portfolio | То же |
|
||||
| M3: проекция | Полный ответ Portfolio (backend — владелец API) | Разрезы, суммы, доли, подписи, имя эмитента (frontend) | HTTP Portfolio → вычисляемая проекция | Получение ответа, смена разреза или валюты; обращений к T-Bank нет |
|
||||
|
||||
Финансовый источник не меняется: количество и цена из GetPortfolio, деньги из GetPositions; sector и issuer деньги не пересчитывают (`backend/src/modules/investments/portfolio/application/portfolio.service.ts:263-315`; `backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:33-55,63-98`).
|
||||
|
||||
### Источники-кандидаты в T-Bank
|
||||
|
||||
Выбор между точечным запросом и пригодными ранее сохранёнными данными — design: Specs его не определяют. Сопоставление идёт по instrument UID, затем по FIGI (PVΔ:3); эмитент по названию, тикеру или ISIN не определяется.
|
||||
|
||||
| Потребность | Кандидат и поля ответа | Подтверждение / ограничения |
|
||||
| --- | --- | --- |
|
||||
| STOCK: sector, asset UID | `ShareBy(InstrumentRequest)` → `Share.sector`, `Share.asset_uid` | RPC `backend/src/integrations/tbank/proto/contracts/instruments.proto:65`; поля `:760,:776` |
|
||||
| BOND: sector, asset UID | `BondBy(InstrumentRequest)` → `Bond.sector`, `Bond.asset_uid` | RPC `instruments.proto:24`; поля `:525,:543` |
|
||||
| FUND: sector, asset UID | `EtfBy(InstrumentRequest)` → `Etf.sector`, `Etf.asset_uid` | RPC `instruments.proto:42`; поля `:644,:657` |
|
||||
| Общая идентичность | `GetInstrumentBy` → `Instrument.uid`, `figi`, `asset_uid` | `asset_uid` есть (`instruments.proto:971`); поля `sector` в `Instrument` нет, поэтому им одним сектор не получить |
|
||||
| Бренд актива | `GetAssetBy(AssetRequest{id: asset_uid})` → `AssetFull.brand.uid`, `brand.name` | `instruments.proto:1030-1036,1051-1068,1220-1229`; `asset_uid` — ключ запроса, не значение `issuer.id` |
|
||||
| Список активов | `GetAssets` → `Asset[]` | У `Asset` нет `brand` (`instruments.proto:1075-1080`); для `issuer` не подходит |
|
||||
|
||||
### Что уже есть в коде
|
||||
|
||||
- GetInstrumentBy по UID и FIGI даёт общую metadata. Его `brand` — `BrandData` (`logo_name`, цвета), не `Brand` с `uid`/`name` (`backend/src/integrations/tbank/tbank.client.ts:293-305,493-509`; `backend/src/integrations/tbank/proto/contracts/common.proto:120-124`). Эмитента из `logoName` или названия инструмента получить нельзя.
|
||||
- Каталог акций и облигаций получает `ShareBy`/`BondBy` и переносит `sector`/`assetUid`; типы `TbankShare`/`TbankBond` их содержат (`backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:341-351`; `backend/src/modules/investments/instruments/application/public-instrument.ts:6-23`; `backend/src/integrations/tbank/tbank-market.types.ts:8-23,48-61`). Это отдельный источник, и пользователь не должен сначала открывать карточку инструмента.
|
||||
- `GetAssetBy` уже вызывается ради `description`; `brand` из ответа не передаётся (`backend/src/integrations/tbank/tbank.client.ts:420-429`; `backend/src/modules/investments/instruments/application/catalog-enrichment.service.ts:431-437`). `GetAssets` в TypeScript интеграции не используется.
|
||||
- **Расхождения AS-IS с нормами, которые закрывает реализация** (это требования, а не выбор):
|
||||
- запись общей metadata считается свежей по факту завершённого lookup, без возраста (`backend/src/integrations/tbank/instrument-metadata.repository.ts:314-323`) — норма PVΔ:141 требует срок от последнего успешного получения;
|
||||
- клиент превращает NOT_FOUND в `NotFoundException` только для `shareBy` и `getInstrumentBy`, остальные методы дают Unavailable (`backend/src/integrations/tbank/tbank.client.ts:577-583`) — по PVΔ:156 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».
|
||||
|
||||
## Маппинг полей
|
||||
|
||||
В колонке «При ошибке» исход определяется таблицей PVΔ:156; «не найдено» и «значения нет» — один исход. Колонка не повторяет решений таблицы.
|
||||
|
||||
| Поле цели | Поля источника | Преобразование | Единица / валюта / scale | Nullability | Default | Валидация | При ошибке |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `position.sector` | `Share.sector` / `Bond.sector` / `Etf.sector` по типу STOCK / BOND / FUND | Обрезать пробелы по краям; пусто или только пробелы → «значения нет»; остальное как получено, без lowercase, перевода и классификации | Код, без валюты | string или null | `null` | Строка; ответ принадлежит запрошенному инструменту | По PVΔ:156; `issuer` и финансы не затрагиваются |
|
||||
| `position.sector` у остальных типов | — | Всегда `null`, получение не нужно | — | `null` | `null` | Тип позиции из нормализации | — |
|
||||
| `position.issuer.id` | `AssetFull.brand.uid` | Обрезать пробелы; перенос без перекодирования | Непрозрачный идентификатор | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки; `AssetFull.uid` совпадает с запрошенным `asset_uid` | По PVΔ:156 |
|
||||
| `position.issuer.name` | `AssetFull.brand.name` | Обрезать пробелы; не `brand.company`, не `asset.name` | Текст | Только внутри объекта | Весь `issuer` = `null` | Непусто после обрезки | По PVΔ:156 |
|
||||
| `position.issuer` | Предыдущие две строки одного ответа | `{id, name}` только если оба пригодны, иначе «значения нет»; STOCK / BOND / FUND, включая FUND; прочие типы `null` | Объект | object или null | `null` | Частичный объект запрещён | По PVΔ:156, объект целиком |
|
||||
| Время последнего успешного получения | Исход получения | Меняется и не меняется по таблице PVΔ:156; читается условием PVΔ:141; ведётся отдельно для `sector` и `issuer` (исходы двух запросов могут различаться) | Момент времени | Нет значения до первого успешного получения | Нет | — | — |
|
||||
| Новые ключи старого Snapshot | Отсутствуют | Отдавать как `null` | — | как в контракте | `null` | Отличать от отсутствия полного Portfolio | Snapshot не отбрасывать |
|
||||
| Название сектора | `position.sector` | Точное совпадение с PC:374 → подпись; иное непустое значение → как получено; `null` → «Сектор не указан» | Текст отображения | Название непустое | Группа «Сектор не указан» | Ключ группировки — код, не подпись | Неизвестный код не объединять с `null` |
|
||||
| Название эмитента | `issuer.name` позиции с наибольшим `currentValue` среди учитываемых позиций одного `issuer.id` в выбранной валюте | Группировать по `issuer.id`; при равных наибольших значениях — меньшее название по алфавитному порядку PC:274; `null` → «Эмитент не указан» | Текст отображения | Название непустое | Группа «Эмитент не указан» | Одинаковое `name` при разных `id` не объединяет | Нет |
|
||||
| Сумма элемента / разреза | `currentValue`, `type`, `sector`, `issuer.id` | Отбор → сумма по ключу; без округления | Десятичная строка Money, одна валюта | Для учтённых элементов | Пустое состояние | Не смешивать валюты | Непригодные sector/issuer меняют группу, не сумму |
|
||||
| Доля элемента, метрика топ-5 | Сумма элемента, сумма разреза | Точное отношение; метрика — сумма точных долей первых пяти; округление только при показе | Доля → % | Нет при пустом разрезе | Пустое состояние | Знаменатель > 0 | Не делить на ноль, не выдавать NaN |
|
||||
|
||||
**Нормализация строк.** Источник правил — PVΔ:3: сектор «как определяет T-Bank, без перекодирования», края обрезаются, значение из одних пробелов отсутствует. Регистр не меняется: `energy` и `Energy` — разные коды (PC:364 запрещает объединять разные `sector`). Для `issuer.id` Specs требуют непустоту после обрезки; обрезка возвращаемого `id` следует решению 3081 «Пробелы обрезать» и нужна, чтобы идентификатор не расщеплял группу.
|
||||
|
||||
**Что считается «значения нет».** Таблица PVΔ:156 использует понятие, но не перечисляет признаки; mapping задаёт их для полей этого документа: нет поля `sector`, `asset_uid`, `brand` в ответе; пустая строка либо только пробелы после обрезки; пара `brand.uid` / `brand.name` непригодна. Ответ о другом инструменте или активе распознаётся несовпадением UID / FIGI / `asset_uid` с запросом (проверка UID в AS-IS: `backend/src/integrations/tbank/instrument-metadata.service.ts:141-160`).
|
||||
|
||||
**Подписи секторов.** Нормативный справочник — PC:374: consumer, energy, financial, health_care, industrials, it, materials, real_estate, telecom, utilities, government. Это только отображение; `Brand.sector` как источник не используется. `other` остаётся `other` (PC:364).
|
||||
|
||||
### Применимость по типу позиции
|
||||
|
||||
| Portfolio type | `sector` | `issuer` | Участие в разрезах |
|
||||
| --- | --- | --- | --- |
|
||||
| STOCK | `Share.sector` | `AssetFull.brand` или `null` | Акции, Секторы, Эмитенты |
|
||||
| BOND | `Bond.sector` | `AssetFull.brand` или `null` | Облигации, Секторы, Эмитенты |
|
||||
| FUND | `Etf.sector` | `AssetFull.brand` или `null`; **заполняется** (PVΔ:3, решение 3081) | Только Секторы; разрез «Эмитенты» фонд отбрасывает (PC:69) |
|
||||
| CURRENCY, FUTURE, OPTION, STRUCTURED_NOTE, DIGITAL_FINANCIAL_ASSET, OTHER | Всегда `null` | Всегда `null` | Ни в одном из четырёх разрезов |
|
||||
|
||||
Типы share → STOCK, bond → BOND, etf → FUND нормализуются mapper'ом (`backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts:22-31`); sector и issuer тип не меняют.
|
||||
|
||||
## Инварианты
|
||||
|
||||
| ID | Правило | Защищаемое Requirement |
|
||||
| --- | --- | --- |
|
||||
| I1 | Обогащение не удаляет, не объединяет и не переставляет позиции; одинаковый FIGI не означает дубликат | PVΔ:3 |
|
||||
| I2 | quantity, `currentValue`, `averagePurchasePrice`, `absoluteReturn`, `cashBalances` и агрегаты не зависят от sector/issuer; `null` не делает стоимость неизвестной. Исключение — отказ token (PVΔ:104) | PVΔ:3; PC:484 |
|
||||
| I3 | В одном разрезе позиция участвует не более одного раза; фонд попадает в «Секторы», но не в «Акции», «Облигации», «Эмитенты» | PC:69 |
|
||||
| I4 | Учитываются только позиции с `currentValue` > 0 в выбранной валюте; отрицательные и неоценённые исключаются, не берутся по модулю | PC:129 |
|
||||
| I5 | Знаменатель = сумма учитываемых позиций разреза в выбранной валюте = сумма элементов; Cash Balance и `totalsByCurrency` не участвуют; `null`-группа входит в сумму | PC:289, PC:349 |
|
||||
| I6 | «Сектор не указан» и «Эмитент не указан» ранжируются наравне с остальными, не подменяются кодом T-Bank и не сливаются с реальным значением | PC:349 |
|
||||
| I7 | До 6 элементов — все строки; от 7 — пять и «Прочее» (сумма остальных, число остальных); метрика «доля топ-5» — только при ≥ 6 элементах | PC:229, PC:324 |
|
||||
| I8 | Порядок: сумма по убыванию; при равенстве — название (цифры, латиница, кириллица), затем порядок первой позиции. Название эмитента — по позиции с наибольшим `currentValue`, при равных — меньшее по алфавиту; сумму и группировку оно не меняет | PC:274, PC:114 |
|
||||
| I9 | Точные доли дают ровно 100 %; «Прочее» = сумма долей остальных; округление только при показе; сумма показанных долей может не быть 100 % | PC:289, PC:304 |
|
||||
| I10 | Список, диаграмма и центр используют одни суммы; раскрытие «Прочее» не меняет диаграмму; в центре — сумма разреза и, при метрике, «Топ-5 · N %» | PC:254, PC:402, PC:415 |
|
||||
| I11 | Валюты вкладки — ненулевые оценки STOCK/BOND/FUND (включая отрицательные), по ISO; выбора нет при ≤ 1 валюте | PC:169, PC:184 |
|
||||
| I12 | N сноски = позиции разреза с `currentValue` `null` в любой валюте + отрицательные в выбранной; нулевые и отрицательные другой валюты не считаются; `null` sector/issuer не считается | PC:144 |
|
||||
| I13 | Название — представление, не идентификатор: разные `issuer.id` и разные `sector` не объединяются при одинаковой подписи | PC:96, PC:364 |
|
||||
| I14 | Нет учтённых элементов — пустое состояние без долей и диаграммы; сноска и выбор валюты сохраняются | PC:451 |
|
||||
| I15 | `sector` и `issuer` отличны от `null` только у STOCK/BOND/FUND; значения обрезаны; `issuer` — пара целиком | PVΔ:3 |
|
||||
| I16 | Поле и отсчёт 24 часов после получения определяются только таблицей PVΔ:156: ни один исход, кроме строк «Получено значение» и «Ответ „не найдено“ или значения нет», не меняет значение и не сдвигает отсчёт | PVΔ:156, PVΔ:141 |
|
||||
|
||||
Доля может быть периодической дробью. Требование 100 % относится к точным суммам, а не к сумме показанных процентов; последнюю долю подгонять нельзя. Текущий frontend helper использует `Number` (`frontend/src/entities/portfolio/model/display.ts:7-24`): он задаёт стиль показа, но не доказывает достаточную точность. Способ точного расчёта — design.
|
||||
|
||||
## Риски двойного учёта и классификации
|
||||
|
||||
| Ситуация | Риск | Правило |
|
||||
| --- | --- | --- |
|
||||
| Обыкновенная акция, привилегированная акция и облигации с одним `brand.uid` | Разные компании либо слияние самих позиций | В «Эмитентах» суммируется один `id`; в «Акциях» и «Облигациях» позиции остаются отдельными |
|
||||
| Одна компания под разными брендами | Занижение концентрации | Разные `id` не объединять; эмитент — бренд T-Bank, а не юридическое лицо. Покрытие — Q-D1 |
|
||||
| Разные бренды с одинаковым `name` | Слияние по подписи | Два элемента (PC:96) |
|
||||
| Один `brand.uid`, разные `name` у позиций | Неоднозначное имя | Имя позиции с наибольшим `currentValue` в выбранной валюте; при равных — меньшее по алфавиту (PC:114) |
|
||||
| Фонд с тем же `brand.uid`, что у акции | Повторный учёт или учёт фонда как компании | `issuer` фонда есть в API, но «Эмитенты» его отбрасывают; в сумму эмитента фонд не входит |
|
||||
| Фонд с `sector` | Раскладка состава фонда | Используется только `Etf.sector` позиции; состав, `focus_type`, название и управляющая компания не используются |
|
||||
| Один бренд, разные сектора у акции и облигации | Подмена сектора эмитента | Каждая позиция в своём `sector`; сектор из `issuer` не выводится |
|
||||
| Государственная облигация | Классификация по типу или названию | «Государственный сектор» только при `sector` = `government` |
|
||||
| Cash, `CURRENCY` и денежный фонд | Двойной учёт денег | Cash и `CURRENCY` вне концентрации; фонд — по общим правилам |
|
||||
| Повторяющийся FIGI | Дедупликация настоящих строк | Позиции не удаляются; повтор строки upstream и повтор доставки lookup — разные явления |
|
||||
| Одна позиция в четырёх разрезах | Общий итог разрезов | Разрезы независимы, между ними не складывать |
|
||||
| Раскрытое «Прочее» | Двойной учёт остатка | Детализация той же суммы, не новые сегменты |
|
||||
|
||||
## Ошибки преобразования
|
||||
|
||||
| Ошибка | Распознавание | Данные |
|
||||
| --- | --- | --- |
|
||||
| Нет надёжного UID/FIGI | Отсутствие ключа | Значение не присваивается; поле остаётся прежним или `null` |
|
||||
| Ответ другого инструмента или актива | Несовпадение UID / FIGI / `asset_uid` с запросом | Исход «ответ о другом инструменте или активе» по PVΔ:156 |
|
||||
| Пустой / только пробелы `sector` | Результат обрезки | Признак «значения нет» |
|
||||
| Новый неизвестный непустой `sector` | Вне таблицы PC:374 | Не ошибка: код сохраняется и показывается как есть |
|
||||
| Нет `asset_uid`, актива или бренда | Отсутствие звена цепочки | Признак «значения нет» |
|
||||
| Пустые `brand.uid` или `brand.name` после обрезки | Непригодная пара | Признак «значения нет» для `issuer` целиком |
|
||||
| `Brand.sector` использован вместо `Share/Bond/Etf.sector` | Нарушение M1 | Ошибка маппинга, даже если значения совпадают |
|
||||
| Приведение кода к lowercase, перевод кода при выдаче | Изменённый ключ | Ошибка контракта; перевод только в подписи frontend |
|
||||
| Старый JSON без ключей | Ключей нет | `null` на HTTP-границе |
|
||||
| Потеря точности суммы, доля после округления | Нарушение I5, I9 | Округлённые подписи не служат источником данных |
|
||||
| Тип вне STOCK/BOND/FUND имеет `sector`/`issuer` | Нарушение I15 | Принудительно `null` |
|
||||
|
||||
Сетевые ошибки, повторы, сроки и восстановление — [failure-model.md](failure-model.md).
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Закрыто решениями владельца ([3081](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3081), [3082](https://git.personal.ksv741.ru/WealthLedger/main/issues/48#issuecomment-3082)):
|
||||
|
||||
- Пробелы — края обрезаются, значение из одних пробелов отсутствует (PVΔ:3; 3081).
|
||||
- `issuer` у фонда — заполняется для STOCK/BOND/FUND (PVΔ:3), разрез фонд отбрасывает (PC:69) (3081).
|
||||
- Разные названия одного бренда — название позиции с наибольшим `currentValue`; при равных — по алфавиту (PC:114; 3081, 3082).
|
||||
- «Не найдено» от T-Bank — как «значения нет» (PVΔ:156; 3082).
|
||||
- Исключение из правила «справочные сведения онлайн» для сектора и бренда (PVΔ:104; 3082).
|
||||
|
||||
Остаётся неизвестным. Это факты данных, они проверяются при реализации и решения владельца не требуют.
|
||||
|
||||
| ID | Что неизвестно | Чем проверяется |
|
||||
| --- | --- | --- |
|
||||
| Q-D1 | Доля позиций STOCK / BOND / FUND, у которых есть пригодная пара `brand.uid` / `brand.name`; совпадает ли `brand.uid` у акций, префов и облигаций одной компании | Одноразовая read-only проверка на реальном Portfolio владельца в начале реализации: доля позиций с непустой парой и таблица «эмитент → типы бумаг». Низкое покрытие scope не меняет (`proposal.md:17`), но определяет, как будет выглядеть разрез на реальных данных |
|
||||
| Q-D2 | Какие `sector` реально отдаются для фондов и государственных облигаций | Та же проверка: список различных значений `sector` по типам со счётчиками; сверка с таблицей PC:374. Неизвестные коды обрабатываются по PC:364 и решения не требуют |
|
||||
Reference in New Issue
Block a user