diff --git a/openspec/changes/archive/2026-08-17-redesign-position-cards/.openspec.yaml b/openspec/changes/archive/2026-08-17-redesign-position-cards/.openspec.yaml new file mode 100644 index 0000000..1496314 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-redesign-position-cards/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/archive/2026-08-17-redesign-position-cards/design.md b/openspec/changes/archive/2026-08-17-redesign-position-cards/design.md new file mode 100644 index 0000000..b69c6f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-redesign-position-cards/design.md @@ -0,0 +1,58 @@ +## Context + +См. proposal.md — Why/What. Текущее состояние: `PositionSection` в `AccountPage.view.tsx:269` рендерит полноширинные `Card`-строки на колонке 960px; позиции уже делятся на секции Акции/Облигации/Другие в upstream-порядке. Backend уже тянет metadata инструмента для каждой позиции (name/ticker) с 24-часовым кэшем; T-Bank в ответе `GetInstrumentBy` дополнительно отдаёт `isin`, но backend его не пробрасывает (`TbankInstrument` типизирован как `{ ticker?, name? }`, `tbank.client.ts:47`). + +Важный факт: `totalsByCurrency` включает **и** Cash Balance (`portfolio.service.ts:148` — `new Map(cash)`), поэтому он непригоден как знаменатель доли позиции. + +## Goals / Non-Goals + +**Goals:** +- Backend отдаёт `isin` позиции без новых обращений к T-Bank. +- Frontend отображает позиции компактными плитками в grid с метриками: идентификатор, количество, `currentValue` (главная), `absoluteReturn` (цвет знака), `averagePurchasePrice`, доля среди позиций той же валюты, `isin`. Null-метрики скрываются. +- Сохранить секции, upstream-порядок, некликабельность, пустые/ошибки/stale-состояния и ровно один способ расчёта доли. + +**Non-Goals:** +- Относительная доходность в %, тикер, сортировка, кликабельные карточки, изменение Totals/Cash/«Последних событий», валютная конвертация. + +## Decisions + +### 1. ISIN прокидывается через существующий metadata-конвейер +`TbankInstrument` расширяется полем `isin?: string`; `position()` в `portfolio.service.ts` возвращает `isin: metadata?.isin?.trim() || null`; `PortfolioPositionDto` получает nullable `isin`. Новых RPC нет — metadata уже запрашивается и кэшируется, меняется только форма значения. +*Альтернатива:* отдельный endpoint инструментов — overkill; маппинг на фронте — невозможно (ISIN нет в данных). + +### 2. Доля позиции считается на клиенте, знаменатель — только позиции одной валюты +Знаменатель = Σ `currentValue` **позиций** одной валюты, без Cash Balance. Сумма вычисляется во `view` (или VM) по всему массиву `positions` один раз (Map валюта → сумма) и передаётся в плитки. Важно: доля считается **по всем секциям сразу**, а не внутри секции — иначе USD-облигация и USD-акция считались бы раздельно. +*Альтернатива:* знаменатель из `totalsByCurrency` — отклонено: он включает кэш и занижает долю. Backend-расчёт — отклонено: доля это отображаемая метрика, спека закрепляет её за клиентом. + +### 3. Расчёт доли — display-only, точность Number с округлением +`display.ts` получает helper `formatShare(numerator, denominator)` → строка процента (1 знак) или `null` при недоступности. Значения money — decimal-строки; для отображения доли достаточно `Number(value)` и округления: доля не участвует в финансовой арифметике и не передаётся в агрегаты. Один маленький `assert`-тест в `display.test.ts`. +*Risk/mitigation:* значения крупнее 2^53 теряют точность — неприменимо к реальным суммам портфеля и безвредно для display-only. + +### 4. Grid — Mantine `SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}` +Тот же компонент, что уже используется для Totals-карточек. Плитки заменяют `Card`-строки в `PositionSection`; на узком экране — одна колонка (спека «Портфель отображается на мобильном устройстве» остаётся верной). + +### 5. Структура плитки +- Верх: `displayName` (усечение), `isin` (dim, `mono`, xs) под именем, количество (dim, sm). +- Справа/ниже: `currentValue` крупно (главная метрика), `absoluteReturn` с цветом знака. +- Низ: `averagePurchasePrice` и доля (dim, xs). + +### 6. Цвета — Mantine-переменные +Зелёный `var(--mantine-color-green-6)` / красный `var(--mantine-color-red-6)` для `absoluteReturn`; без цвета при `null`. Кастомную палитру не вводим. + +### 7. Null-метрики скрываются +Вместо «—» строка не рендерится (компактная плитка). Это меняет текущий тест `AccountPage.test.tsx:94` (`expect(screen.getByText("—"))`). + +## Risks / Trade-offs + +- [Точность доли при Number] → доля display-only, округление до 1 знака; upgrade до BigInt-деления, если появится сценарий, требующий точности. +- [ISIN отсутствует у валют и части инструментов] → nullable; плитка скрывает строку ISIN. +- [Grid-плитки на узком экране] → `base: 1` гарантирует одноколоночность. +- [«—»-заглушка в тестах] → обновить `AccountPage.test.tsx` на проверку скрытия метрик. + +## Migration Plan + +Обратимая аддитивная миграция. Backend-поле `isin` nullable: старый frontend игнорирует лишнее поле; старый backend без `isin` даёт `undefined` → плитка скрывает строку. Откат — revert соответствующего коммита в репозитории. Снапшот OpenAPI в frontend обновляется `npm run openapi:sync` после изменения backend. + +## Open Questions + +Нет. Детали типографики и отступов плитки решаются на этапе реализации без изменения спек. diff --git a/openspec/changes/archive/2026-08-17-redesign-position-cards/proposal.md b/openspec/changes/archive/2026-08-17-redesign-position-cards/proposal.md new file mode 100644 index 0000000..a80d5aa --- /dev/null +++ b/openspec/changes/archive/2026-08-17-redesign-position-cards/proposal.md @@ -0,0 +1,27 @@ +## Why + +Position Card на странице Account Portfolio выглядит вытянутой: полноширинный ряд на колонке 960px с данными только по краям и пустой серединой. Показываются всего четыре метрики, хотя API уже отдаёт `averagePurchasePrice`, а ISIN инструмента доступен у T-Bank, но не пробрасывается backend-ом. Карточка не даёт ответить на «сколько эта позиция весит в портфеле» и «по какой цене куплена» с первого взгляда. + +## What Changes + +- **Backend**: `PortfolioPositionDto` получает nullable `isin` из metadata инструмента (T-Bank уже возвращает `isin` в ответах `GetInstrumentBy`); тип `TbankInstrument` расширяется полем `isin`. +- **Frontend**: Position Card перестаёт быть полноширинной строкой и становится компактной плиткой в grid (`base: 1, sm: 2, md: 3`). Плитка показывает: `displayName`, количество, `currentValue` (главная метрика), `absoluteReturn` (цвет зелёный/красный), `averagePurchasePrice`, долю позиции среди позиций той же валюты, ISIN (dim, моноширинный, под именем). Null-метрики скрываются, а не отображаются как «—». +- **Не меняется**: upstream-порядок внутри секций, разбиение на секции Акции/Облигации/Другие, Totals-карточки, Cash Balance, «Последние события», остальные страницы. Плитки некликабельны. Относительная доходность в % и тикер не добавляются. +- **Документация**: `CONTEXT.md` получает термин Instrument; `docs/features/portfolio-view.md` обновляет описание отображения позиций и поля `isin`. + +## Capabilities + +### New Capabilities + +- (нет новых возможностей — изменение затрагивает только существующий portfolio-view) + +### Modified Capabilities + +- `portfolio-view`: Portfolio Position получает nullable `isin`; клиент отображает позиции компактными плитками в grid с расширенным составом метрик (средняя цена покупки, доля среди позиций той же валюты, ISIN), скрывает отсутствующие метрики и раскрашивает доход. + +## Impact + +- **backend** (`/api/v1/brokerage-accounts/:accountId/portfolio`): `PortfolioPositionDto` + поле `isin`; тип `TbankInstrument`; маппинг в `portfolio.service.ts`; тесты `portfolio.service.spec.ts`. +- **frontend**: OpenAPI snapshot и `schema.d.ts` (`openapi:sync`), `display.ts` (расчёт доли), `AccountPage.view.tsx`, `AccountPage.module.css`, i18n `ru.ts`, тесты `AccountPage.test.tsx` и `display.test.ts`. +- **docs**: `CONTEXT.md`, `docs/features/portfolio-view.md`. +- Внешних зависимостей и breaking-изменений нет: `isin` добавляется как nullable-поле. diff --git a/openspec/changes/archive/2026-08-17-redesign-position-cards/specs/portfolio-view/spec.md b/openspec/changes/archive/2026-08-17-redesign-position-cards/specs/portfolio-view/spec.md new file mode 100644 index 0000000..32ad2c2 --- /dev/null +++ b/openspec/changes/archive/2026-08-17-redesign-position-cards/specs/portfolio-view/spec.md @@ -0,0 +1,77 @@ +## MODIFIED Requirements + +### Requirement: Нормализованный состав Portfolio +Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок 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`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения. + +#### Scenario: Позиция с неизвестным upstream-типом +- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента +- **THEN** система возвращает её в исходном порядке с типом `OTHER` + +#### 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** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты +- **THEN** система возвращает один Cash Balance с их точной суммой + +#### Scenario: Штатный пустой Portfolio +- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков +- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency` + +### Requirement: Ошибки и состояния Portfolio на клиенте +Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд, без automatic retry, polling или refetch при возврате фокуса. После истечения срока клиент MUST скрыть предыдущий ответ и показать loading-состояние до нового результата. + +Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки. + +Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. Position Card MUST отображаться как компактная плитка в адаптивном grid: одна колонка на узком экране и несколько колонок на широком. Плитка MUST показывать отображаемый идентификатор позиции, количество, `currentValue` как главную метрику, `absoluteReturn` с цветовой индикацией знака, `averagePurchasePrice`, долю позиции среди позиций той же валюты и `isin`. Отсутствующие метрики MUST быть скрыты, а не отображаться заглушкой. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. + +#### Scenario: Показ устаревшего Portfolio +- **WHEN** клиент получает успешный ответ с `isStale: true` +- **THEN** клиент показывает позиции и остатки, время последнего успешного снимка и предупреждение без действия ручного обновления + +#### Scenario: Недоступный счёт на клиенте +- **WHEN** клиент получает `404` для Portfolio +- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора + +#### Scenario: Портфель отображается на мобильном устройстве +- **WHEN** User открывает загруженный Portfolio на узком экране +- **THEN** клиент сохраняет читаемое одноколоночное отображение агрегатов, позиций и Cash Balance + +#### Scenario: Отдельные акции и облигации +- **WHEN** Portfolio содержит позиции `STOCK` и `BOND` +- **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции + +#### Scenario: Карточка позиции отображает доступные метрики +- **WHEN** позиция имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN +- **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю среди позиций той же валюты и ISIN + +#### Scenario: Отсутствующие метрики скрыты +- **WHEN** у позиции отсутствует значение текущей стоимости, средней цены покупки или дохода +- **THEN** клиент скрывает соответствующую метрику вместо отображения заглушки + +#### Scenario: Доход окрашен по знаку +- **WHEN** у позиции абсолютный доход положительный или отрицательный +- **THEN** клиент отображает его зелёным для положительного и красным для отрицательного, без цвета при его отсутствии + +#### Scenario: Доля позиции в валюте +- **WHEN** позиция имеет вычислимую текущую стоимость в валюте +- **THEN** клиент отображает её долю среди всех позиций той же валюты, не включая Cash Balance diff --git a/openspec/changes/archive/2026-08-17-redesign-position-cards/tasks.md b/openspec/changes/archive/2026-08-17-redesign-position-cards/tasks.md new file mode 100644 index 0000000..70f097e --- /dev/null +++ b/openspec/changes/archive/2026-08-17-redesign-position-cards/tasks.md @@ -0,0 +1,37 @@ +## 1. Backend: expose ISIN + +- [x] 1.1 Расширить тип `TbankInstrument` полем `isin?: string` в `tbank.client.ts` +- [x] 1.2 Добавить nullable `isin` в `PortfolioPositionDto` в `portfolio.dto.ts` +- [x] 1.3 Возвращать `isin: metadata?.isin?.trim() || null` в `position()` в `portfolio.service.ts` +- [x] 1.4 Обновить `portfolio.service.spec.ts`: моки и ожидания `isin`, включая отсутствие ISIN +- [x] 1.5 Проверить backend: `npm run lint`, `npm test`, `npm run build` + +## 2. Frontend: OpenAPI contract и display-helper + +- [x] 2.1 Обновить snapshot и `schema.d.ts`: `npm run openapi:sync` (поле `isin`) +- [x] 2.2 Добавить helper `formatShare(numerator, denominator)` в `display.ts` (строка %, 1 знак, `null` при недоступности) +- [x] 2.3 Добавить unit-тесты `formatShare` в `display.test.ts` + +## 3. Frontend: Position Card grid-плитки + +- [x] 3.1 Заменить `Card`-строки в `PositionSection` на `SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}` в `AccountPage.view.tsx` +- [x] 3.2 Вычислить во view сумму `currentValue` позиций по каждой валюте (без Cash Balance) и передать её плиткам +- [x] 3.3 Рендерить плитку: имя, `isin` (mono, dim), количество, `currentValue`, `absoluteReturn` (зелёный/красный), `averagePurchasePrice`, долю; скрывать null-метрики +- [x] 3.4 Добавить стили плитки в `AccountPage.module.css` +- [x] 3.5 Добавить i18n-ключи в `ru.ts` (средняя цена покупки, доля, ISIN) + +## 4. Frontend: тесты + +- [x] 4.1 Обновить `AccountPage.test.tsx`: заменить проверку «—» на скрытие null, добавить проверки плиток, `isin`, доли и сохранения upstream-порядка +- [x] 4.2 Проверить frontend: `npm run verify` + +## 5. Документация + +- [x] 5.1 Добавить термин Instrument в `CONTEXT.md` +- [x] 5.2 Обновить `docs/features/portfolio-view.md`: grid-плитки, метрики, `isin`, доля; API-секция про `isin` + +## 6. Верификация и архив + +- [x] 6.1 Прогнать `opsx-verify` по change +- [ ] 6.2 Архив change через `openspec archive` и синхронизация delta в main spec +- [ ] 6.3 Закоммитить по репозиториям: specs, docs, backend, frontend diff --git a/openspec/specs/portfolio-view/spec.md b/openspec/specs/portfolio-view/spec.md index 192af80..42e7d29 100644 --- a/openspec/specs/portfolio-view/spec.md +++ b/openspec/specs/portfolio-view/spec.md @@ -20,7 +20,7 @@ ### Requirement: Нормализованный состав Portfolio Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок 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`. Система 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`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения. #### Scenario: Позиция с неизвестным upstream-типом - **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента @@ -38,6 +38,14 @@ - **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** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты - **THEN** система возвращает один Cash Balance с их точной суммой @@ -89,7 +97,7 @@ Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки. -Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. +Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. Position Card MUST отображаться как компактная плитка в адаптивном grid: одна колонка на узком экране и несколько колонок на широком. Плитка MUST показывать отображаемый идентификатор позиции, количество, `currentValue` как главную метрику, `absoluteReturn` с цветовой индикацией знака, `averagePurchasePrice`, долю позиции среди позиций той же валюты и `isin`. Отсутствующие метрики MUST быть скрыты, а не отображаться заглушкой. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. #### Scenario: Показ устаревшего Portfolio - **WHEN** клиент получает успешный ответ с `isStale: true` @@ -106,3 +114,19 @@ #### Scenario: Отдельные акции и облигации - **WHEN** Portfolio содержит позиции `STOCK` и `BOND` - **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции + +#### Scenario: Карточка позиции отображает доступные метрики +- **WHEN** позиция имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN +- **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю среди позиций той же валюты и ISIN + +#### Scenario: Отсутствующие метрики скрыты +- **WHEN** у позиции отсутствует значение текущей стоимости, средней цены покупки или дохода +- **THEN** клиент скрывает соответствующую метрику вместо отображения заглушки + +#### Scenario: Доход окрашен по знаку +- **WHEN** у позиции абсолютный доход положительный или отрицательный +- **THEN** клиент отображает его зелёным для положительного и красным для отрицательного, без цвета при его отсутствии + +#### Scenario: Доля позиции в валюте +- **WHEN** позиция имеет вычислимую текущую стоимость в валюте +- **THEN** клиент отображает её долю среди всех позиций той же валюты, не включая Cash Balance