Files

6.9 KiB
Raw Permalink Blame History

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

Нет. Детали типографики и отступов плитки решаются на этапе реализации без изменения спек.