59 lines
6.9 KiB
Markdown
59 lines
6.9 KiB
Markdown
## 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
|
||
|
||
Нет. Детали типографики и отступов плитки решаются на этапе реализации без изменения спек.
|