spec: count currency mirrors once in portfolio (WealthLedger/specs#60)
Conventional commits / commits (pull_request) Successful in 11s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s

This commit is contained in:
2026-11-05 07:14:05 +03:00
parent 9cb1c58597
commit 1f22510cb7
2 changed files with 10 additions and 4 deletions
@@ -8,6 +8,8 @@
### Requirement: Полные оценки Portfolio сохраняются как история ### Requirement: Полные оценки Portfolio сохраняются как история
Система SHALL сохранять для каждого доступного Brokerage Account точку оценки по каждой исходной валюте только после успешного получения полного Portfolio с вычислимой стоимостью всех его позиций. Точка MUST содержать точное значение без валютной конвертации и фактическое время успешного получения Portfolio. Система MUST NOT подставлять ноль за отсутствующую стоимость и MUST NOT создавать точку для неполной оценки. Повторная публикация одной попытки MUST NOT создавать дубликат точки. Система SHALL сохранять для каждого доступного Brokerage Account точку оценки по каждой исходной валюте только после успешного получения полного Portfolio с вычислимой стоимостью всех его позиций. Точка MUST содержать точное значение без валютной конвертации и фактическое время успешного получения Portfolio. Система MUST NOT подставлять ноль за отсутствующую стоимость и MUST NOT создавать точку для неполной оценки. Повторная публикация одной попытки MUST NOT создавать дубликат точки.
Точки, сохранённые до исправления двойного учёта `CURRENCY`/Cash Balance (WealthLedger/backend#140), сброшены миграцией. История оценок начинается заново после её применения; старые точки MUST NOT показываться как сопоставимые с новыми.
#### Scenario: Полная мультивалютная оценка #### Scenario: Полная мультивалютная оценка
- **WHEN** успешный полный Portfolio содержит вычислимые итоги RUB и USD - **WHEN** успешный полный Portfolio содержит вычислимые итоги RUB и USD
- **THEN** система сохраняет две отдельные точки с исходными значениями и валютами - **THEN** система сохраняет две отдельные точки с исходными значениями и валютами
@@ -20,7 +20,7 @@ Portfolio Snapshot является результатом наблюдения
- **THEN** система отвечает `404` без Portfolio Snapshot - **THEN** система отвечает `404` без Portfolio Snapshot
### Requirement: Нормализованный состав Portfolio ### 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. Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Portfolio Position с типом `CURRENCY` MUST сохранять исходное количество и идентификаторы для контракта, но отражает тот же кэш, что и Cash Balance, и MUST NOT повторно учитываться в валютных итогах и структуре. Клиент MUST показывать этот кэш один раз как Cash Balance в разделе «Деньги», без отдельной карточки `CURRENCY`. Порядок 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, не скрывая финансовые значения. Каждая 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, не скрывая финансовые значения.
@@ -124,7 +124,11 @@ Portfolio Snapshot является результатом наблюдения
### Requirement: Точные значения и валютные агрегаты ### Requirement: Точные значения и валютные агрегаты
Все количества и финансовые значения в Portfolio Snapshot MUST передаваться десятичными строками без экспоненциальной записи и без незначащих завершающих нулей. Денежное значение MUST быть единым Money-значением с ISO-кодом валюты и десятичной строкой значения. Система MUST корректно нормализовать отрицательные целую и дробную части upstream-значений. Отсутствующее значение MUST передаваться как `null`, а не как ноль. Все количества и финансовые значения в Portfolio Snapshot MUST передаваться десятичными строками без экспоненциальной записи и без незначащих завершающих нулей. Денежное значение MUST быть единым Money-значением с ISO-кодом валюты и десятичной строкой значения. Система MUST корректно нормализовать отрицательные целую и дробную части upstream-значений. Отсутствующее значение MUST передаваться как `null`, а не как ноль.
`currentValue` Portfolio Position MUST быть точным произведением количества на текущую цену при наличии валидных множителей и валюты цены. `absoluteReturn` MUST быть точной величиной `(currentPrice - averagePurchasePrice) * quantity` только когда обе цены имеют одинаковую известную валюту. Позиция без вычислимой текущей стоимости MUST оставаться в ответе, но MUST NOT входить в агрегаты. `totalsByCurrency` MUST содержать точную сумму currentValue позиций и Cash Balance в каждой валюте без валютной конвертации. `currentValue` Portfolio Position MUST быть точным произведением количества на текущую цену при наличии валидных множителей и валюты цены. `absoluteReturn` MUST быть точной величиной `(currentPrice - averagePurchasePrice) * quantity` только когда обе цены имеют одинаковую известную валюту. Позиция без вычислимой текущей стоимости MUST оставаться в ответе, но MUST NOT входить в агрегаты. `totalsByCurrency` MUST содержать точную сумму currentValue позиций, кроме `CURRENCY`, и Cash Balance в каждой валюте без валютной конвертации. Структура Portfolio MUST учитывать `CURRENCY` только как `CASH` из Cash Balance, без второго вклада.
#### Scenario: Валютная позиция отражает денежный остаток
- **WHEN** Portfolio содержит `CURRENCY` position стоимостью 100 RUB и Cash Balance 100 RUB
- **THEN** система сохраняет `CURRENCY` position с количеством в `positions`, но возвращает итог 100 RUB и один вклад `CASH` 100 RUB в структуре; клиент показывает 100 RUB в «Деньги» без отдельной валютной карточки
#### Scenario: Мультивалютный Portfolio #### Scenario: Мультивалютный Portfolio
- **WHEN** Portfolio содержит финансовые значения в RUB и USD - **WHEN** Portfolio содержит финансовые значения в RUB и USD
@@ -282,7 +286,7 @@ Portfolio и предложить возврат к списку счетов.
- **THEN** клиент показывает ISIN и не заменяет его тикером - **THEN** клиент показывает ISIN и не заменяет его тикером
#### Scenario: Карточка позиции отображает доступные метрики #### Scenario: Карточка позиции отображает доступные метрики
- **WHEN** позиция, не относящаяся к `STOCK`, имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN - **WHEN** позиция, не относящаяся к `STOCK` или `CURRENCY`, имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN
- **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю позиции среди позиций той же валюты и ISIN - **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю позиции среди позиций той же валюты и ISIN
#### Scenario: Отсутствующие метрики скрыты #### Scenario: Отсутствующие метрики скрыты
@@ -295,7 +299,7 @@ Portfolio и предложить возврат к списку счетов.
#### Scenario: Доля позиции в валюте #### Scenario: Доля позиции в валюте
- **WHEN** позиция имеет вычислимую текущую стоимость в валюте - **WHEN** позиция имеет вычислимую текущую стоимость в валюте
- **THEN** клиент отображает её долю среди всех позиций той же валюты, не включая Cash Balance - **THEN** клиент отображает её долю среди всех отображаемых позиций той же валюты, не включая Cash Balance и зеркальные `CURRENCY` positions
#### Scenario: Одноколоночные секции акций и облигаций #### Scenario: Одноколоночные секции акций и облигаций
- **WHEN** User открывает загруженный Portfolio с позициями `STOCK` или `BOND` на широком экране - **WHEN** User открывает загруженный Portfolio с позициями `STOCK` или `BOND` на широком экране