- Move canonical specs into domain folders (identity, tbank, brokerage, portfolio, instruments, banking, frontend-platform, backend-platform). - Remove process specs; keep still-valid unique rules in openspec/config.yaml. - Keep project-foundation behavior as app-shell and http-api-foundation. - Archive account-top-positions without G4 by owner decision (descoped review/QA recorded in process-record); sync portfolio-concentration and portfolio-view. - Group AI_INDEX by domain. Refs: WealthLedger/main#48
26 KiB
Маппинг данных
Источник и цель
Документ не выбирает внутренний способ загрузки и хранения. Обозначения PC, PVΔ и правило нумерации строк — в 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, 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 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».
- запись общей metadata считается свежей по факту завершённого lookup, без возраста (
Маппинг полей
В колонке «При ошибке» исход определяется таблицей 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.
Открытые вопросы
Закрыто решениями владельца (3081, 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 и решения не требуют |