From 8e578e7a9130166c0cbc3f1c5ba8c497ccea9d2c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 12:47:09 +0300 Subject: [PATCH] refactor(frontend): move broker position slice to fsd --- .../broker-position/api/brokerPositionApi.ts | 3 + .../src/entities/broker-position/index.ts | 17 + .../model/brokerAllocation.test.ts | 144 ++++++++ .../broker-position/model/brokerAllocation.ts | 85 +++++ .../model/brokerDisplay.test.ts | 227 +++++++++++++ .../broker-position/model/brokerDisplay.ts | 177 ++++++++++ .../model/useBrokerPositions.ts | 18 + apps/frontend/src/hooks/useBrokerPositions.ts | 19 +- .../ui/BrokerAccountOverviewPage.tsx | 179 +++++++++- .../ui/BrokerPositionsPage.tsx | 320 +++++++++++++++++- .../broker/BrokerAccountOverviewPage.tsx | 179 +--------- .../src/pages/broker/BrokerAllocationBar.tsx | 44 +-- .../pages/broker/BrokerAllocationChart.tsx | 90 +---- .../src/pages/broker/BrokerPages.test.tsx | 2 +- .../src/pages/broker/BrokerPositionsPage.tsx | 317 +---------------- .../src/pages/broker/brokerAllocation.ts | 90 +---- .../src/pages/broker/brokerDisplay.ts | 187 +--------- .../ui/BrokerAccountCard.tsx | 4 +- .../ui/BrokerAccountsSummary.tsx | 6 +- .../widgets/broker-allocation-chart/index.ts | 1 + .../ui/BrokerAllocationChart.tsx | 134 ++++++++ 21 files changed, 1329 insertions(+), 914 deletions(-) create mode 100644 apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts create mode 100644 apps/frontend/src/entities/broker-position/index.ts create mode 100644 apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts create mode 100644 apps/frontend/src/entities/broker-position/model/brokerAllocation.ts create mode 100644 apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts create mode 100644 apps/frontend/src/entities/broker-position/model/brokerDisplay.ts create mode 100644 apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts create mode 100644 apps/frontend/src/widgets/broker-allocation-chart/index.ts create mode 100644 apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx diff --git a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts new file mode 100644 index 0000000..6d86eb1 --- /dev/null +++ b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts @@ -0,0 +1,3 @@ +import { getBrokerPositions } from '../../../api/broker'; + +export { getBrokerPositions }; diff --git a/apps/frontend/src/entities/broker-position/index.ts b/apps/frontend/src/entities/broker-position/index.ts new file mode 100644 index 0000000..b87fad9 --- /dev/null +++ b/apps/frontend/src/entities/broker-position/index.ts @@ -0,0 +1,17 @@ +export { getBrokerPositions } from './api/brokerPositionApi'; +export { + buildBrokerAllocation, + type BrokerAllocationItem, + type BrokerAllocationKey, +} from './model/brokerAllocation'; +export { + BROKER_OPERATION_TYPE_OPTIONS, + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, + isBrokerOperationType, + type BrokerOperationImpact, + type BrokerPositionGroup, +} from './model/brokerDisplay'; +export { useBrokerPositions } from './model/useBrokerPositions'; diff --git a/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts b/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts new file mode 100644 index 0000000..e1736cb --- /dev/null +++ b/apps/frontend/src/entities/broker-position/model/brokerAllocation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses'; +import { buildBrokerAllocation } from './brokerAllocation'; + +function money(value: number): BrokerMoney { + return { + currency: 'RUB', + units: String(Math.trunc(value)), + nano: 0, + value, + }; +} + +function portfolio( + values: Partial>, +): BrokerPortfolio { + const total = (key: keyof typeof values): BrokerMoney | null => { + const value = values[key]; + return value == null ? null : money(value); + }; + + return { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Основной', + status: 'open', + openedAt: '2024-01-01T00:00:00.000Z', + accessLevel: 'full_access', + }, + positionCounts: { + shares: 1, + bonds: 1, + etf: 1, + other: 0, + }, + totals: { + shares: total('shares'), + bonds: total('bonds'), + etf: total('etf'), + currencies: total('currencies'), + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: total('portfolio'), + }, + yields: { + expectedPercent: null, + daily: null, + dailyPercent: null, + }, + cash: [], + blockedCash: [], + asOf: '2025-01-01T00:00:00.000Z', + }; +} + +describe('buildBrokerAllocation', () => { + it('builds allocation sectors in display order', () => { + expect( + buildBrokerAllocation( + portfolio({ shares: 400, bonds: 300, etf: 100, currencies: 150, portfolio: 1000 }), + ), + ).toEqual({ + total: 1000, + sectors: [ + { key: 'shares', label: 'Акции', value: 400, percent: 40, color: '#4969f5' }, + { key: 'bonds', label: 'Облигации', value: 300, percent: 30, color: '#e5a33c' }, + { key: 'etf', label: 'ETF/фонды', value: 100, percent: 10, color: '#62b889' }, + { key: 'cash', label: 'Деньги', value: 150, percent: 15, color: '#7b63cf' }, + { key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' }, + ], + negative: [], + }); + }); + + it('omits zero-value sectors', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']); + expect(result.negative).toEqual([]); + }); + + it('reports a negative residual outside the sectors', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']); + expect(result.negative).toEqual([ + { key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' }, + ]); + }); + + it('ignores a tiny negative residual caused by decimal arithmetic', () => { + const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 })); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']); + expect(result.negative).toEqual([]); + }); + + it('ignores a tiny positive residual caused by decimal arithmetic', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares']); + expect(result.negative).toEqual([]); + }); + + it('returns no allocation for missing or nonpositive portfolio totals', () => { + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({ + total: 0, + sectors: [], + negative: [], + }); + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({ + total: 0, + sectors: [], + negative: [], + }); + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({ + total: -10, + sectors: [], + negative: [], + }); + }); + + it('preserves named negative components when the portfolio total is nonpositive', () => { + expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({ + total: 0, + sectors: [], + negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }], + }); + expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({ + total: -10, + sectors: [], + negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }], + }); + }); +}); diff --git a/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts new file mode 100644 index 0000000..446d0cf --- /dev/null +++ b/apps/frontend/src/entities/broker-position/model/brokerAllocation.ts @@ -0,0 +1,85 @@ +import type { BrokerPortfolio } from '../../../api/responses'; + +export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'; + +export interface BrokerAllocationItem { + key: BrokerAllocationKey; + label: string; + value: number; + percent: number; + color: string; +} + +type BrokerNegativeAllocationItem = Omit; + +const ALLOCATION_CONFIG: Array> = [ + { key: 'shares', label: 'Акции', color: '#4969f5' }, + { key: 'bonds', label: 'Облигации', color: '#e5a33c' }, + { key: 'etf', label: 'ETF/фонды', color: '#62b889' }, + { key: 'cash', label: 'Деньги', color: '#7b63cf' }, + { key: 'other', label: 'Прочие', color: '#aeb6c5' }, +]; + +export function buildBrokerAllocation(portfolio: BrokerPortfolio): { + total: number; + sectors: BrokerAllocationItem[]; + negative: BrokerNegativeAllocationItem[]; +} { + const total = portfolio.totals.portfolio?.value ?? 0; + const shares = portfolio.totals.shares?.value ?? 0; + const bonds = portfolio.totals.bonds?.value ?? 0; + const etf = portfolio.totals.etf?.value ?? 0; + const cash = portfolio.totals.currencies?.value ?? 0; + const namedValues: Record, number> = { + shares, + bonds, + etf, + cash, + }; + + if (total <= 0) { + const negative = ALLOCATION_CONFIG.filter( + ( + item, + ): item is (typeof ALLOCATION_CONFIG)[number] & { + key: Exclude; + } => item.key !== 'other', + ) + .filter((item) => namedValues[item.key] < 0) + .map((item) => ({ ...item, value: namedValues[item.key] })); + return { total, sectors: [], negative }; + } + + const mappedTotal = shares + bonds + etf + cash; + const residual = total - mappedTotal; + const residualTolerance = + Number.EPSILON * + Math.max( + 1, + Math.abs(total), + Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash), + ) * + 8; + const values: Record = { + shares, + bonds, + etf, + cash, + other: Math.abs(residual) <= residualTolerance ? 0 : residual, + }; + + const sectors: BrokerAllocationItem[] = []; + const negative: BrokerNegativeAllocationItem[] = []; + + for (const item of ALLOCATION_CONFIG) { + const value = values[item.key]; + + if (value > 0) { + sectors.push({ ...item, value, percent: (value / total) * 100 }); + } else if (value < 0) { + negative.push({ ...item, value }); + } + } + + return { total, sectors, negative }; +} diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts new file mode 100644 index 0000000..4f3497c --- /dev/null +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerOperation, BrokerPosition } from '../../../api/responses'; +import { + BROKER_OPERATION_TYPE_OPTIONS, + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, + isBrokerOperationType, +} from './brokerDisplay'; + +function position(input: Partial): BrokerPosition { + return { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: null, + classCode: null, + instrumentType: null, + name: null, + quantity: null, + blockedLots: null, + currentPrice: null, + currentValue: null, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + ...input, + }; +} + +function operation(input: Partial): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: null, + parentOperationId: null, + date: null, + type: 'OPERATION_TYPE_UNSPECIFIED', + category: 'other', + description: null, + name: null, + state: null, + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: null, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + ...input, + }; +} + +describe('broker display helpers', () => { + it('groups positions by instrument type', () => { + expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares'); + expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds'); + expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other'); + expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other'); + }); + + it('builds stock and bond routes from instrument metadata', () => { + expect( + getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ + ticker: 'SU26238RMFS5', + instrumentType: 'bond', + classCode: 'TQOB', + }), + ).toBe('/bonds/SU26238RMFS5'); + expect( + getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), + ).toBeNull(); + expect( + getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), + ).toBeNull(); + }); + + it('uses class code fallback when instrument type is missing', () => { + expect( + getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), + ).toBe('/bonds/RU000A0JX0J2'); + }); + + it('does not let class code override a known unsupported or conflicting instrument type', () => { + expect( + getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }), + ).toBeNull(); + expect( + getBrokerInstrumentPath({ + ticker: 'SU26238RMFS5', + instrumentType: 'bond', + classCode: 'TQBR', + }), + ).toBe('/bonds/SU26238RMFS5'); + }); + + it('maps operation enum values to Russian labels', () => { + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( + 'Выплата купона', + ); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог'); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка'); + expect( + getBrokerOperationTypeLabel( + operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), + ), + ).toBe('Custom'); + }); + + it('exposes independently selectable known operation types', () => { + expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual( + expect.arrayContaining([ + { value: 'OPERATION_TYPE_COUPON', label: 'Выплата купона' }, + { value: 'OPERATION_TYPE_TAX', label: 'Налог' }, + { value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' }, + { value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' }, + ]), + ); + }); + + it('keeps operation type option values unique and labels in Russian order', () => { + const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value); + const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label); + + expect(new Set(values).size).toBe(values.length); + expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))); + }); + + it('keeps operation type options immutable at runtime', () => { + expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true); + expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true); + }); + + it('validates only exact known operation type values', () => { + expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true); + expect(isBrokerOperationType('operation_type_coupon')).toBe(false); + expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false); + expect(isBrokerOperationType(null)).toBe(false); + }); + + it('classifies operations by portfolio impact', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_COUPON', + category: 'income', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + }), + ), + ).toBe('adds'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_TAX', + category: 'tax', + payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, + }), + ), + ).toBe('reduces'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_SELL', + category: 'trade', + payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + }), + ), + ).toBe('neutral'); + expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( + 'unknown', + ); + }); + + it('keeps unknown operation types unclear even when they have non-zero payments', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', + category: 'other', + payment: { currency: 'RUB', units: '100', nano: 0, value: 100 }, + }), + ), + ).toBe('unknown'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', + category: 'other', + payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 }, + }), + ), + ).toBe('unknown'); + }); + + it('classifies known income operation types as additions even with weak metadata', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_COUPON', + category: 'other', + payment: null, + }), + ), + ).toBe('adds'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_DIVIDEND', + category: 'other', + payment: null, + }), + ), + ).toBe('adds'); + }); +}); diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts new file mode 100644 index 0000000..3479e78 --- /dev/null +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts @@ -0,0 +1,177 @@ +import type { BrokerOperation, BrokerPosition } from '../../../api/responses'; + +export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; +export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; + +type BrokerInstrumentLinkInput = { + ticker: string | null; + instrumentType: string | null; + classCode: string | null; +}; + +const STOCK_CLASS_CODES = new Set(['TQBR']); +const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); + +const TRADE_TYPES = new Set([ + 'OPERATION_TYPE_BUY', + 'OPERATION_TYPE_BUY_CARD', + 'OPERATION_TYPE_SELL', + 'OPERATION_TYPE_SELL_CARD', + 'OPERATION_TYPE_BUY_MARGIN', + 'OPERATION_TYPE_SELL_MARGIN', + 'OPERATION_TYPE_DELIVERY_BUY', + 'OPERATION_TYPE_DELIVERY_SELL', +]); + +const BOND_REPAYMENT_TYPES = new Set([ + 'OPERATION_TYPE_BOND_REPAYMENT', + 'OPERATION_TYPE_BOND_REPAYMENT_FULL', +]); + +const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']); + +const TAX_TYPES = new Set([ + 'OPERATION_TYPE_TAX', + 'OPERATION_TYPE_BOND_TAX', + 'OPERATION_TYPE_DIVIDEND_TAX', + 'OPERATION_TYPE_TAX_CORRECTION', + 'OPERATION_TYPE_TAX_CORRECTION_COUPON', +]); + +const FEE_TYPES = new Set([ + 'OPERATION_TYPE_BROKER_FEE', + 'OPERATION_TYPE_SERVICE_FEE', + 'OPERATION_TYPE_MARGIN_FEE', + 'OPERATION_TYPE_SUCCESS_FEE', +]); + +const TRANSFER_INPUT_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_INP_MULTI', +]); + +const TRANSFER_OUTPUT_TYPES = new Set([ + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_OUT_MULTI', +]); + +const SECURITY_TRANSFER_TYPES = new Set([ + 'OPERATION_TYPE_INPUT_SECURITIES', + 'OPERATION_TYPE_OUTPUT_SECURITIES', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', +]); + +const OPERATION_TYPE_LABELS: Record = { + OPERATION_TYPE_BUY: 'Покупка', + OPERATION_TYPE_BUY_CARD: 'Покупка', + OPERATION_TYPE_SELL: 'Продажа', + OPERATION_TYPE_SELL_CARD: 'Продажа', + OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой', + OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой', + OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки', + OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи', + OPERATION_TYPE_COUPON: 'Выплата купона', + OPERATION_TYPE_DIVIDEND: 'Дивиденды', + OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации', + OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации', + OPERATION_TYPE_TAX: 'Налог', + OPERATION_TYPE_BOND_TAX: 'Налог по облигациям', + OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды', + OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога', + OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону', + OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера', + OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание', + OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу', + OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат', + OPERATION_TYPE_INPUT: 'Пополнение', + OPERATION_TYPE_OUTPUT: 'Вывод средств', + OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', + OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', +}; + +export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< + Readonly<{ value: string; label: string }> +> = Object.freeze( + Object.entries(OPERATION_TYPE_LABELS) + .map(([value, label]) => Object.freeze({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, 'ru')), +); + +const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)); + +export function isBrokerOperationType(value: string | null): value is string { + return value !== null && BROKER_OPERATION_TYPES.has(value); +} + +export function getBrokerPositionGroup( + position: Pick, +): BrokerPositionGroup { + const instrumentType = position.instrumentType?.toLowerCase(); + + if (instrumentType === 'share') return 'shares'; + if (instrumentType === 'bond') return 'bonds'; + + return 'other'; +} + +export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { + const ticker = input.ticker?.trim().toUpperCase(); + if (!ticker) return null; + + const instrumentType = input.instrumentType?.toLowerCase(); + const classCode = input.classCode?.toUpperCase() ?? null; + + if (instrumentType === 'share') { + return `/stocks/${encodeURIComponent(ticker)}`; + } + + if (instrumentType === 'bond') { + return `/bonds/${encodeURIComponent(ticker)}`; + } + + if (instrumentType) return null; + + if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`; + if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`; + + return null; +} + +export function getBrokerOperationTypeLabel( + operation: Pick, +): string { + const knownLabel = OPERATION_TYPE_LABELS[operation.type]; + if (knownLabel) return knownLabel; + if (operation.description) return operation.description; + + return operation.type + .replace(/^OPERATION_TYPE_/, '') + .replace(/_/g, ' ') + .toLowerCase(); +} + +export function getBrokerOperationImpact( + operation: Pick, +): BrokerOperationImpact { + if ( + TRADE_TYPES.has(operation.type) || + BOND_REPAYMENT_TYPES.has(operation.type) || + SECURITY_TRANSFER_TYPES.has(operation.type) + ) { + return 'neutral'; + } + + if (INCOME_TYPES.has(operation.type)) return 'adds'; + if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces'; + if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'; + if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'; + if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'; + if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'; + + return 'unknown'; +} diff --git a/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts new file mode 100644 index 0000000..764f60c --- /dev/null +++ b/apps/frontend/src/entities/broker-position/model/useBrokerPositions.ts @@ -0,0 +1,18 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import type { BrokerPositionsPage } from '../../../api/responses'; +import { getBrokerPositions } from '../api/brokerPositionApi'; + +export function useBrokerPositions( + accountId: string | undefined, + query: { cursor?: string; limit?: number; type?: string } = {}, +) { + return useQuery({ + queryKey: ['broker', 'positions', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPositions(accountId!, query)).data, + staleTime: 60_000, + retry: 2, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerPositions.ts b/apps/frontend/src/hooks/useBrokerPositions.ts index 79903ba..eb3f4ed 100644 --- a/apps/frontend/src/hooks/useBrokerPositions.ts +++ b/apps/frontend/src/hooks/useBrokerPositions.ts @@ -1,18 +1 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { getBrokerPositions } from '../api/broker'; -import type { BrokerPositionsPage } from '../api/responses'; - -export function useBrokerPositions( - accountId: string | undefined, - query: { cursor?: string; limit?: number; type?: string } = {}, -) { - return useQuery({ - queryKey: ['broker', 'positions', accountId, query], - enabled: Boolean(accountId), - queryFn: async () => (await getBrokerPositions(accountId!, query)).data, - staleTime: 60_000, - retry: 2, - placeholderData: keepPreviousData, - refetchOnWindowFocus: false, - }); -} +export { useBrokerPositions } from '../entities/broker-position'; diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index 6270d13..ff42380 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -1 +1,178 @@ -export { BrokerAccountOverviewPage } from '../../broker/BrokerAccountOverviewPage'; +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses'; +import { SkeletonBlock } from '../../../components/SkeletonBlock'; +import { useBrokerOperations } from '../../../hooks/useBrokerOperations'; +import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout'; +import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart'; +import { BrokerOperationsTable } from '../../broker/BrokerOperationsTable'; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '—'; + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatPercent(value: number | null) { + if (value === null) return '—'; + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; +} + +function pluralize(count: number, one: string, few: string, many: string) { + const modulo100 = Math.abs(count) % 100; + const modulo10 = modulo100 % 10; + if (modulo100 > 10 && modulo100 < 20) return many; + if (modulo10 === 1) return one; + if (modulo10 >= 2 && modulo10 <= 4) return few; + return many; +} + +function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { + return ( +
+
+ Стоимость портфеля + + {formatMoney(portfolio.totals.portfolio)} + + За день: {formatMoney(portfolio.yields.daily)} + Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} + Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} +
+
+ Денежный остаток + {portfolio.cash.length === 0 ? ( + Нет денежных остатков + ) : ( +
    + {portfolio.cash.map((money, index) => ( +
  • + {money.currency} + {formatMoney(money)} +
  • + ))} +
+ )} +
+
+ ); +} + +function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { + if (!value || !total || total.value <= 0) return null; + return (value.value / total.value) * 100; +} + +function formatAllocationPercent(value: number | null) { + return value === null ? '—' : `${value.toFixed(1)}%`; +} + +function BrokerAssetCards({ + accountId, + portfolio, +}: { + accountId: string; + portfolio: BrokerPortfolio; +}) { + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const cards = [ + { + label: 'Акции', + count: portfolio.positionCounts.shares, + countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), + value: portfolio.totals.shares, + path: `${basePath}/shares`, + }, + { + label: 'Облигации', + count: portfolio.positionCounts.bonds, + countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), + value: portfolio.totals.bonds, + path: `${basePath}/bonds`, + }, + ]; + + return ( +
+ {cards.map((card) => ( + + {card.label} + + {card.count} {card.countLabel} + + {formatMoney(card.value)} + + {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} + + + ))} +
+ ); +} + +function BrokerOverviewSkeleton() { + return ( +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ ); +} + +export function BrokerAccountOverviewPage() { + const { accountId, portfolio } = useBrokerAccountContext(); + const operations = useBrokerOperations(accountId, { limit: 5 }); + + if (portfolio.isLoading) return ; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить сводку счёта

; + } + + return ( +
+ + + + {operations.error ? ( +

Не удалось загрузить последние операции

+ ) : ( + Вся история + } + emptyMessage="Операций с начала текущего года нет" + isLoading={operations.isLoading} + isFetching={operations.isFetching} + page={operations.data} + /> + )} +
+ ); +} diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index 50b6bc0..c33847d 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -1 +1,319 @@ -export { BrokerPositionsPage } from '../../broker/BrokerPositionsPage'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { + BrokerMoney, + BrokerPosition, + BrokerPositionsPage as BrokerPositionsPageData, +} from '../../../api/responses'; +import { TableSkeleton } from '../../../components/TableSkeleton'; +import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position'; +import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout'; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const pagButtonStyle = { + padding: '6px 14px', + borderRadius: 6, + border: '1px solid #e0e0e0', + background: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + lineHeight: 1.4, +} satisfies React.CSSProperties; + +const pagButtonDisabledStyle = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +} satisfies React.CSSProperties; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} + +function BrokerPositionTable({ + title, + page, + isLoading, + isFetching, + emptyMessage, + pageNumber, + onNext, + onPrevious, +}: { + title: string; + page: BrokerPositionsPageData | undefined; + isLoading: boolean; + isFetching: boolean; + emptyMessage: string; + pageNumber: number; + onNext: () => void; + onPrevious: () => void; +}) { + const positions = page?.items ?? []; + const canGoBack = pageNumber > 1; + const canGoForward = Boolean(page?.hasNext && page.nextCursor); + + return ( +
+
+

+ {title} +

+
+ + + {pageNumber} + + +
+
+ + {isLoading ? ( +
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+ ) : positions.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+ )} +
+ ); +} + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor }); + + function handleNext() { + const nextCursor = positions.data?.nextCursor; + if (!nextCursor || !positions.data?.hasNext) return; + setCursorStack((previous) => [...previous, cursor]); + setCursor(nextCursor); + } + + function handlePrevious() { + if (cursorStack.length === 0) return; + setCursor(cursorStack[cursorStack.length - 1]); + setCursorStack((previous) => previous.slice(0, -1)); + } + + if (positions.error) { + return ( +
+

+ {title} +

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + + ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx index 2b95a47..a4aeaaf 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx @@ -1,178 +1 @@ -import { Link } from 'react-router-dom'; -import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; -import { SkeletonBlock } from '../../components/SkeletonBlock'; -import { useBrokerOperations } from '../../hooks/useBrokerOperations'; -import { useBrokerAccountContext } from './BrokerAccountLayout'; -import { BrokerAllocationChart } from './BrokerAllocationChart'; -import { BrokerOperationsTable } from './BrokerOperationsTable'; - -function formatMoney(value: BrokerMoney | null | undefined) { - if (!value) return '—'; - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: value.currency || 'RUB', - maximumFractionDigits: 2, - }).format(value.value); -} - -function formatPercent(value: number | null) { - if (value === null) return '—'; - return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; -} - -function pluralize(count: number, one: string, few: string, many: string) { - const modulo100 = Math.abs(count) % 100; - const modulo10 = modulo100 % 10; - if (modulo100 > 10 && modulo100 < 20) return many; - if (modulo10 === 1) return one; - if (modulo10 >= 2 && modulo10 <= 4) return few; - return many; -} - -function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { - return ( -
-
- Стоимость портфеля - - {formatMoney(portfolio.totals.portfolio)} - - За день: {formatMoney(portfolio.yields.daily)} - Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} - Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} -
-
- Денежный остаток - {portfolio.cash.length === 0 ? ( - Нет денежных остатков - ) : ( -
    - {portfolio.cash.map((money, index) => ( -
  • - {money.currency} - {formatMoney(money)} -
  • - ))} -
- )} -
-
- ); -} - -function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { - if (!value || !total || total.value <= 0) return null; - return (value.value / total.value) * 100; -} - -function formatAllocationPercent(value: number | null) { - return value === null ? '—' : `${value.toFixed(1)}%`; -} - -function BrokerAssetCards({ - accountId, - portfolio, -}: { - accountId: string; - portfolio: BrokerPortfolio; -}) { - const basePath = `/broker/${encodeURIComponent(accountId)}`; - const cards = [ - { - label: 'Акции', - count: portfolio.positionCounts.shares, - countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), - value: portfolio.totals.shares, - path: `${basePath}/shares`, - }, - { - label: 'Облигации', - count: portfolio.positionCounts.bonds, - countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), - value: portfolio.totals.bonds, - path: `${basePath}/bonds`, - }, - ]; - - return ( -
- {cards.map((card) => ( - - {card.label} - - {card.count} {card.countLabel} - - {formatMoney(card.value)} - - {formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))} - - - ))} -
- ); -} - -function BrokerOverviewSkeleton() { - return ( -
-
- {[1, 2].map((item) => ( -
- - - -
- ))} -
-
- - -
-
- {[1, 2].map((item) => ( -
- - - -
- ))} -
-
- ); -} - -export function BrokerAccountOverviewPage() { - const { accountId, portfolio } = useBrokerAccountContext(); - const operations = useBrokerOperations(accountId, { limit: 5 }); - - if (portfolio.isLoading) return ; - if (portfolio.error || !portfolio.data) { - return

Не удалось загрузить сводку счёта

; - } - - return ( -
- - - - {operations.error ? ( -

Не удалось загрузить последние операции

- ) : ( - Вся история - } - emptyMessage="Операций с начала текущего года нет" - isLoading={operations.isLoading} - isFetching={operations.isFetching} - page={operations.data} - /> - )} -
- ); -} +export { BrokerAccountOverviewPage } from '../broker-account'; diff --git a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx index d4203f4..11df303 100644 --- a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx +++ b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx @@ -1,43 +1 @@ -import type { BrokerAllocationItem } from './brokerAllocation'; - -export function BrokerAllocationBar({ - items, - title, -}: { - items: BrokerAllocationItem[]; - title: string; -}) { - const positiveItems = items.filter((item) => item.value > 0); - - if (positiveItems.length === 0) { - return

Нет данных для распределения

; - } - - return ( -
-
- {positiveItems.map((item) => ( -
-
    - {positiveItems.map((item) => ( -
  • -
  • - ))} -
-
- ); -} +export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart'; diff --git a/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx b/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx index 88830f2..59fd817 100644 --- a/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx +++ b/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx @@ -1,89 +1 @@ -import type { BrokerPortfolio } from '../../api/responses'; -import { buildBrokerAllocation } from './brokerAllocation'; - -const RADIUS = 44; -const CIRCUMFERENCE = 2 * Math.PI * RADIUS; - -function formatMoneyValue(value: number, currency: string) { - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency, - maximumFractionDigits: 2, - }).format(value); -} - -function allocationCurrency(portfolio: BrokerPortfolio) { - return ( - portfolio.totals.portfolio?.currency || - Object.values(portfolio.totals).find((total) => total?.currency)?.currency || - 'RUB' - ); -} - -export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { - const { sectors, negative } = buildBrokerAllocation(portfolio); - const currency = allocationCurrency(portfolio); - let remaining = CIRCUMFERENCE; - const arcs = sectors.map((sector) => { - const dashOffset = -(CIRCUMFERENCE - remaining); - const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE; - const dashLength = Math.min(Math.max(rawDashLength, 0), remaining); - remaining = Math.max(0, remaining - dashLength); - return { ...sector, dashOffset, dashLength }; - }); - - return ( -
- - Структура брокерского портфеля - {arcs.map((sector) => ( - - ))} - -
- {sectors.length === 0 ? ( -

Нет данных для распределения

- ) : ( -
    - {sectors.map((sector) => ( -
  • -
  • - ))} -
- )} - {negative.length > 0 && ( -
    - {negative.map((item) => ( -
  • - {item.label}: отрицательное значение {formatMoneyValue(item.value, currency)} -
  • - ))} -
- )} -
-
- ); -} +export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart'; diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 64c2c1d..08ddafc 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -8,8 +8,8 @@ import * as operationsHook from '../../hooks/useBrokerOperations'; import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; -import * as positionsHook from '../../hooks/useBrokerPositions'; import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses'; +import * as positionsHook from '../../entities/broker-position'; import { AppRoutes } from '../../routes'; import { renderWithProviders } from '../../test/test-utils'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; diff --git a/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx index 7cccb20..4c45f43 100644 --- a/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx @@ -1,316 +1 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { BrokerMoney, BrokerPosition, BrokerPositionsPage } from '../../api/responses'; -import { getBrokerInstrumentPath } from './brokerDisplay'; -import { TableSkeleton } from '../../components/TableSkeleton'; -import { useBrokerPositions } from '../../hooks/useBrokerPositions'; -import { useBrokerAccountContext } from './BrokerAccountLayout'; - -const tableStyle = { - width: '100%', - borderCollapse: 'collapse', - fontSize: 14, -} satisfies React.CSSProperties; - -const thStyle = { - borderBottom: '1px solid #e0e0e0', - color: 'var(--color-text-secondary)', - fontWeight: 600, - padding: '10px 8px', -} satisfies React.CSSProperties; - -const tdStyle = { - borderBottom: '1px solid #eeeeee', - padding: '10px 8px', - verticalAlign: 'top', -} satisfies React.CSSProperties; - -const pagButtonStyle = { - padding: '6px 14px', - borderRadius: 6, - border: '1px solid #e0e0e0', - background: 'var(--color-surface)', - color: 'var(--color-text)', - fontSize: 14, - fontWeight: 600, - cursor: 'pointer', - lineHeight: 1.4, -} satisfies React.CSSProperties; - -const pagButtonDisabledStyle = { - ...pagButtonStyle, - opacity: 0.35, - cursor: 'not-allowed', -} satisfies React.CSSProperties; - -function formatMoney(value: BrokerMoney | null | undefined) { - if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: value.currency || 'RUB', - maximumFractionDigits: 2, - }).format(value.value); -} - -function formatQuantity(value: number | null | undefined) { - return value == null ? '-' : value.toLocaleString('ru-RU'); -} - -function PositionTicker({ position }: { position: BrokerPosition }) { - const label = position.ticker || position.figi || '-'; - const path = getBrokerInstrumentPath({ - ticker: position.ticker, - instrumentType: position.instrumentType, - classCode: position.classCode, - }); - - if (!path || label === '-') { - return {label}; - } - - return ( - - {label} - - ); -} - -function BrokerPositionTable({ - title, - page, - isLoading, - isFetching, - emptyMessage, - pageNumber, - onNext, - onPrevious, -}: { - title: string; - page: BrokerPositionsPage | undefined; - isLoading: boolean; - isFetching: boolean; - emptyMessage: string; - pageNumber: number; - onNext: () => void; - onPrevious: () => void; -}) { - const positions = page?.items ?? []; - const canGoBack = pageNumber > 1; - const canGoForward = Boolean(page?.hasNext && page.nextCursor); - - return ( -
-
-

- {title} -

-
- - - {pageNumber} - - -
-
- - {isLoading ? ( -
- - - - - - - - - - - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
-
- ) : positions.length === 0 && !isFetching ? ( -

{emptyMessage}

- ) : ( -
-
- - - - - - - - - - - - {positions.map((position) => ( - - - - - - - - ))} - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- - - - {position.name || '-'} - - - {formatQuantity(position.quantity)} - - {formatMoney(position.currentPrice)} - - {formatMoney(position.currentValue)} -
-
- {isFetching && ( -
-
- - Загрузка страницы {pageNumber}… - -
- )} -
- )} -
- ); -} - -type BrokerPositionsPageProps = { - type: 'share' | 'bond'; - title: 'Акции' | 'Облигации'; -}; - -export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { - const { accountId } = useBrokerAccountContext(); - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); - const positions = useBrokerPositions(accountId, { type, limit: 10, cursor }); - - function handleNext() { - const nextCursor = positions.data?.nextCursor; - if (!nextCursor || !positions.data?.hasNext) return; - setCursorStack((previous) => [...previous, cursor]); - setCursor(nextCursor); - } - - function handlePrevious() { - if (cursorStack.length === 0) return; - setCursor(cursorStack[cursorStack.length - 1]); - setCursorStack((previous) => previous.slice(0, -1)); - } - - if (positions.error) { - return ( -
-

- {title} -

-

- {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} -

-
- ); - } - - return ( - - ); -} +export { BrokerPositionsPage } from '../broker-positions'; diff --git a/apps/frontend/src/pages/broker/brokerAllocation.ts b/apps/frontend/src/pages/broker/brokerAllocation.ts index 9455080..3c5822b 100644 --- a/apps/frontend/src/pages/broker/brokerAllocation.ts +++ b/apps/frontend/src/pages/broker/brokerAllocation.ts @@ -1,85 +1,5 @@ -import type { BrokerPortfolio } from '../../api/responses'; - -export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'; - -export interface BrokerAllocationItem { - key: BrokerAllocationKey; - label: string; - value: number; - percent: number; - color: string; -} - -type BrokerNegativeAllocationItem = Omit; - -const ALLOCATION_CONFIG: Array> = [ - { key: 'shares', label: 'Акции', color: '#4969f5' }, - { key: 'bonds', label: 'Облигации', color: '#e5a33c' }, - { key: 'etf', label: 'ETF/фонды', color: '#62b889' }, - { key: 'cash', label: 'Деньги', color: '#7b63cf' }, - { key: 'other', label: 'Прочие', color: '#aeb6c5' }, -]; - -export function buildBrokerAllocation(portfolio: BrokerPortfolio): { - total: number; - sectors: BrokerAllocationItem[]; - negative: BrokerNegativeAllocationItem[]; -} { - const total = portfolio.totals.portfolio?.value ?? 0; - const shares = portfolio.totals.shares?.value ?? 0; - const bonds = portfolio.totals.bonds?.value ?? 0; - const etf = portfolio.totals.etf?.value ?? 0; - const cash = portfolio.totals.currencies?.value ?? 0; - const namedValues: Record, number> = { - shares, - bonds, - etf, - cash, - }; - - if (total <= 0) { - const negative = ALLOCATION_CONFIG.filter( - ( - item, - ): item is (typeof ALLOCATION_CONFIG)[number] & { - key: Exclude; - } => item.key !== 'other', - ) - .filter((item) => namedValues[item.key] < 0) - .map((item) => ({ ...item, value: namedValues[item.key] })); - return { total, sectors: [], negative }; - } - - const mappedTotal = shares + bonds + etf + cash; - const residual = total - mappedTotal; - const residualTolerance = - Number.EPSILON * - Math.max( - 1, - Math.abs(total), - Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash), - ) * - 8; - const values: Record = { - shares, - bonds, - etf, - cash, - other: Math.abs(residual) <= residualTolerance ? 0 : residual, - }; - - const sectors: BrokerAllocationItem[] = []; - const negative: BrokerNegativeAllocationItem[] = []; - - for (const item of ALLOCATION_CONFIG) { - const value = values[item.key]; - - if (value > 0) { - sectors.push({ ...item, value, percent: (value / total) * 100 }); - } else if (value < 0) { - negative.push({ ...item, value }); - } - } - - return { total, sectors, negative }; -} +export { + buildBrokerAllocation, + type BrokerAllocationItem, + type BrokerAllocationKey, +} from '../../entities/broker-position'; diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts index aad7c7a..52b4515 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.ts @@ -1,177 +1,10 @@ -import type { BrokerOperation, BrokerPosition } from '../../api/responses'; - -export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; -export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; - -type BrokerInstrumentLinkInput = { - ticker: string | null; - instrumentType: string | null; - classCode: string | null; -}; - -const STOCK_CLASS_CODES = new Set(['TQBR']); -const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); - -const TRADE_TYPES = new Set([ - 'OPERATION_TYPE_BUY', - 'OPERATION_TYPE_BUY_CARD', - 'OPERATION_TYPE_SELL', - 'OPERATION_TYPE_SELL_CARD', - 'OPERATION_TYPE_BUY_MARGIN', - 'OPERATION_TYPE_SELL_MARGIN', - 'OPERATION_TYPE_DELIVERY_BUY', - 'OPERATION_TYPE_DELIVERY_SELL', -]); - -const BOND_REPAYMENT_TYPES = new Set([ - 'OPERATION_TYPE_BOND_REPAYMENT', - 'OPERATION_TYPE_BOND_REPAYMENT_FULL', -]); - -const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']); - -const TAX_TYPES = new Set([ - 'OPERATION_TYPE_TAX', - 'OPERATION_TYPE_BOND_TAX', - 'OPERATION_TYPE_DIVIDEND_TAX', - 'OPERATION_TYPE_TAX_CORRECTION', - 'OPERATION_TYPE_TAX_CORRECTION_COUPON', -]); - -const FEE_TYPES = new Set([ - 'OPERATION_TYPE_BROKER_FEE', - 'OPERATION_TYPE_SERVICE_FEE', - 'OPERATION_TYPE_MARGIN_FEE', - 'OPERATION_TYPE_SUCCESS_FEE', -]); - -const TRANSFER_INPUT_TYPES = new Set([ - 'OPERATION_TYPE_INPUT', - 'OPERATION_TYPE_INPUT_SWIFT', - 'OPERATION_TYPE_INPUT_ACQUIRING', - 'OPERATION_TYPE_INP_MULTI', -]); - -const TRANSFER_OUTPUT_TYPES = new Set([ - 'OPERATION_TYPE_OUTPUT', - 'OPERATION_TYPE_OUTPUT_SWIFT', - 'OPERATION_TYPE_OUTPUT_ACQUIRING', - 'OPERATION_TYPE_OUT_MULTI', -]); - -const SECURITY_TRANSFER_TYPES = new Set([ - 'OPERATION_TYPE_INPUT_SECURITIES', - 'OPERATION_TYPE_OUTPUT_SECURITIES', - 'OPERATION_TYPE_TRANS_IIS_BS', - 'OPERATION_TYPE_TRANS_BS_BS', -]); - -const OPERATION_TYPE_LABELS: Record = { - OPERATION_TYPE_BUY: 'Покупка', - OPERATION_TYPE_BUY_CARD: 'Покупка', - OPERATION_TYPE_SELL: 'Продажа', - OPERATION_TYPE_SELL_CARD: 'Продажа', - OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой', - OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой', - OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки', - OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи', - OPERATION_TYPE_COUPON: 'Выплата купона', - OPERATION_TYPE_DIVIDEND: 'Дивиденды', - OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации', - OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации', - OPERATION_TYPE_TAX: 'Налог', - OPERATION_TYPE_BOND_TAX: 'Налог по облигациям', - OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды', - OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога', - OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону', - OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера', - OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание', - OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу', - OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат', - OPERATION_TYPE_INPUT: 'Пополнение', - OPERATION_TYPE_OUTPUT: 'Вывод средств', - OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', - OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', -}; - -export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< - Readonly<{ value: string; label: string }> -> = Object.freeze( - Object.entries(OPERATION_TYPE_LABELS) - .map(([value, label]) => Object.freeze({ value, label })) - .sort((left, right) => left.label.localeCompare(right.label, 'ru')), -); - -const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)); - -export function isBrokerOperationType(value: string | null): value is string { - return value !== null && BROKER_OPERATION_TYPES.has(value); -} - -export function getBrokerPositionGroup( - position: Pick, -): BrokerPositionGroup { - const instrumentType = position.instrumentType?.toLowerCase(); - - if (instrumentType === 'share') return 'shares'; - if (instrumentType === 'bond') return 'bonds'; - - return 'other'; -} - -export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { - const ticker = input.ticker?.trim().toUpperCase(); - if (!ticker) return null; - - const instrumentType = input.instrumentType?.toLowerCase(); - const classCode = input.classCode?.toUpperCase() ?? null; - - if (instrumentType === 'share') { - return `/stocks/${encodeURIComponent(ticker)}`; - } - - if (instrumentType === 'bond') { - return `/bonds/${encodeURIComponent(ticker)}`; - } - - if (instrumentType) return null; - - if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`; - if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`; - - return null; -} - -export function getBrokerOperationTypeLabel( - operation: Pick, -): string { - const knownLabel = OPERATION_TYPE_LABELS[operation.type]; - if (knownLabel) return knownLabel; - if (operation.description) return operation.description; - - return operation.type - .replace(/^OPERATION_TYPE_/, '') - .replace(/_/g, ' ') - .toLowerCase(); -} - -export function getBrokerOperationImpact( - operation: Pick, -): BrokerOperationImpact { - if ( - TRADE_TYPES.has(operation.type) || - BOND_REPAYMENT_TYPES.has(operation.type) || - SECURITY_TRANSFER_TYPES.has(operation.type) - ) { - return 'neutral'; - } - - if (INCOME_TYPES.has(operation.type)) return 'adds'; - if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces'; - if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'; - if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'; - if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'; - if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'; - - return 'unknown'; -} +export { + BROKER_OPERATION_TYPE_OPTIONS, + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, + isBrokerOperationType, + type BrokerOperationImpact, + type BrokerPositionGroup, +} from '../../entities/broker-position'; diff --git a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx index f4b160c..4551241 100644 --- a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx +++ b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx @@ -1,8 +1,8 @@ import { Link } from 'react-router-dom'; import { SkeletonBlock } from '../../../components/SkeletonBlock'; import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '../../../api/responses'; -import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation'; -import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar'; +import { buildBrokerAllocation } from '../../../entities/broker-position'; +import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; function formatBrokerCurrencyValue(currency: string, value: number): string { return new Intl.NumberFormat('ru-RU', { diff --git a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx index 1482471..1d8ac2b 100644 --- a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx +++ b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx @@ -1,7 +1,7 @@ import { SkeletonBlock } from '../../../components/SkeletonBlock'; -import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation'; -import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar'; -import type { BrokerAccountsAggregate } from '../../../pages/broker/brokerAccountsOverview'; +import type { BrokerAccountsAggregate } from '../../../entities/broker-account/model/brokerAccountsOverview'; +import { buildBrokerAllocation } from '../../../entities/broker-position'; +import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart'; function formatBrokerCurrencyValue(currency: string, value: number): string { return new Intl.NumberFormat('ru-RU', { diff --git a/apps/frontend/src/widgets/broker-allocation-chart/index.ts b/apps/frontend/src/widgets/broker-allocation-chart/index.ts new file mode 100644 index 0000000..b99acd8 --- /dev/null +++ b/apps/frontend/src/widgets/broker-allocation-chart/index.ts @@ -0,0 +1 @@ +export { BrokerAllocationBar, BrokerAllocationChart } from './ui/BrokerAllocationChart'; diff --git a/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx new file mode 100644 index 0000000..88e39b9 --- /dev/null +++ b/apps/frontend/src/widgets/broker-allocation-chart/ui/BrokerAllocationChart.tsx @@ -0,0 +1,134 @@ +import type { BrokerPortfolio } from '../../../api/responses'; +import { + buildBrokerAllocation, + type BrokerAllocationItem, +} from '../../../entities/broker-position'; + +const RADIUS = 44; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function formatMoneyValue(value: number, currency: string) { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(value); +} + +function allocationCurrency(portfolio: BrokerPortfolio) { + return ( + portfolio.totals.portfolio?.currency || + Object.values(portfolio.totals).find((total) => total?.currency)?.currency || + 'RUB' + ); +} + +export function BrokerAllocationBar({ + items, + title, +}: { + items: BrokerAllocationItem[]; + title: string; +}) { + const positiveItems = items.filter((item) => item.value > 0); + + if (positiveItems.length === 0) { + return

Нет данных для распределения

; + } + + return ( +
+
+ {positiveItems.map((item) => ( +
+
    + {positiveItems.map((item) => ( +
  • +
  • + ))} +
+
+ ); +} + +export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { + const { sectors, negative } = buildBrokerAllocation(portfolio); + const currency = allocationCurrency(portfolio); + let remaining = CIRCUMFERENCE; + const arcs = sectors.map((sector) => { + const dashOffset = -(CIRCUMFERENCE - remaining); + const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE; + const dashLength = Math.min(Math.max(rawDashLength, 0), remaining); + remaining = Math.max(0, remaining - dashLength); + return { ...sector, dashOffset, dashLength }; + }); + + return ( +
+ + Структура брокерского портфеля + {arcs.map((sector) => ( + + ))} + +
+ {sectors.length === 0 ? ( +

Нет данных для распределения

+ ) : ( +
    + {sectors.map((sector) => ( +
  • +
  • + ))} +
+ )} + {negative.length > 0 && ( +
    + {negative.map((item) => ( +
  • + {item.label}: отрицательное значение {formatMoneyValue(item.value, currency)} +
  • + ))} +
+ )} +
+
+ ); +}