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