Files
specs/openspec/changes/archive/2026-11-02-account-top-positions/analysis/data-mapping.md
T
codex-bot 3821d9ed58
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 14s
refactor: group canonical specs by domain and archive account-top-positions (#74)
2026-11-02 21:50:49 +03:00

26 KiB
Raw Blame History

Маппинг данных

Источник и цель

Документ не выбирает внутренний способ загрузки и хранения. Обозначения 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 «не найдено» относится к исходу «значения нет», а не к «иной ошибке».

Маппинг полей

В колонке «При ошибке» исход определяется таблицей 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 и решения не требуют