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