refactor(frontend): move broker position slice to fsd
This commit is contained in:
parent
1db1763b36
commit
8e578e7a91
@ -0,0 +1,3 @@
|
|||||||
|
import { getBrokerPositions } from '../../../api/broker';
|
||||||
|
|
||||||
|
export { getBrokerPositions };
|
||||||
17
apps/frontend/src/entities/broker-position/index.ts
Normal file
17
apps/frontend/src/entities/broker-position/index.ts
Normal file
@ -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';
|
||||||
@ -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<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
|
||||||
|
): 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' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<BrokerAllocationItem, 'percent'>;
|
||||||
|
|
||||||
|
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
||||||
|
{ 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<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
const negative = ALLOCATION_CONFIG.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
||||||
|
key: Exclude<BrokerAllocationKey, 'other'>;
|
||||||
|
} => 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<BrokerAllocationKey, number> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@ -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>): 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>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<string, string> = {
|
||||||
|
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<BrokerPosition, 'instrumentType'>,
|
||||||
|
): 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<BrokerOperation, 'type' | 'description'>,
|
||||||
|
): 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<BrokerOperation, 'type' | 'category' | 'payment'>,
|
||||||
|
): 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';
|
||||||
|
}
|
||||||
@ -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<BrokerPositionsPage>({
|
||||||
|
queryKey: ['broker', 'positions', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,18 +1 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
export { useBrokerPositions } from '../entities/broker-position';
|
||||||
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<BrokerPositionsPage>({
|
|
||||||
queryKey: ['broker', 'positions', accountId, query],
|
|
||||||
enabled: Boolean(accountId),
|
|
||||||
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: 2,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||||||
|
<div className="broker-overview__card">
|
||||||
|
<span className="broker-overview__label">Стоимость портфеля</span>
|
||||||
|
<strong className="broker-overview__total">
|
||||||
|
{formatMoney(portfolio.totals.portfolio)}
|
||||||
|
</strong>
|
||||||
|
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||||||
|
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||||||
|
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="broker-overview__card">
|
||||||
|
<span className="broker-overview__label">Денежный остаток</span>
|
||||||
|
{portfolio.cash.length === 0 ? (
|
||||||
|
<span>Нет денежных остатков</span>
|
||||||
|
) : (
|
||||||
|
<ul className="broker-overview__cash">
|
||||||
|
{portfolio.cash.map((money, index) => (
|
||||||
|
<li key={`${money.currency}-${index}`}>
|
||||||
|
<span>{money.currency}</span>
|
||||||
|
<strong>{formatMoney(money)}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
className="broker-overview__card broker-overview__asset-link"
|
||||||
|
key={card.label}
|
||||||
|
to={card.path}
|
||||||
|
>
|
||||||
|
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||||||
|
<span>
|
||||||
|
{card.count} {card.countLabel}
|
||||||
|
</span>
|
||||||
|
<span>{formatMoney(card.value)}</span>
|
||||||
|
<span>
|
||||||
|
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrokerOverviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||||||
|
<div className="broker-overview__summary">
|
||||||
|
{[1, 2].map((item) => (
|
||||||
|
<div className="broker-overview__card" key={item}>
|
||||||
|
<SkeletonBlock height={16} width="45%" />
|
||||||
|
<SkeletonBlock height={28} width="70%" />
|
||||||
|
<SkeletonBlock height={16} width="55%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="broker-allocation">
|
||||||
|
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||||||
|
<SkeletonBlock height={80} width="60%" />
|
||||||
|
</div>
|
||||||
|
<div className="broker-overview__assets">
|
||||||
|
{[1, 2].map((item) => (
|
||||||
|
<div className="broker-overview__card" key={item}>
|
||||||
|
<SkeletonBlock height={20} width="35%" />
|
||||||
|
<SkeletonBlock height={16} width="55%" />
|
||||||
|
<SkeletonBlock height={16} width="70%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerAccountOverviewPage() {
|
||||||
|
const { accountId, portfolio } = useBrokerAccountContext();
|
||||||
|
const operations = useBrokerOperations(accountId, { limit: 5 });
|
||||||
|
|
||||||
|
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
||||||
|
if (portfolio.error || !portfolio.data) {
|
||||||
|
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-overview">
|
||||||
|
<BrokerSummary portfolio={portfolio.data} />
|
||||||
|
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||||
|
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||||
|
{operations.error ? (
|
||||||
|
<p role="alert">Не удалось загрузить последние операции</p>
|
||||||
|
) : (
|
||||||
|
<BrokerOperationsTable
|
||||||
|
title="Последние операции"
|
||||||
|
headerAction={
|
||||||
|
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||||||
|
}
|
||||||
|
emptyMessage="Операций с начала текущего года нет"
|
||||||
|
isLoading={operations.isLoading}
|
||||||
|
isFetching={operations.isFetching}
|
||||||
|
page={operations.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 <strong>{label}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!canGoBack || isFetching}
|
||||||
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<span
|
||||||
|
className="loading-spinner"
|
||||||
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'←'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Следующая страница"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canGoForward || isFetching}
|
||||||
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<span
|
||||||
|
className="loading-spinner"
|
||||||
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'→'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Тикер
|
||||||
|
</th>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Название
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Количество
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Цена
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Стоимость
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<TableSkeleton
|
||||||
|
rows={4}
|
||||||
|
columns={[
|
||||||
|
{ width: '30%' },
|
||||||
|
{ width: '50%' },
|
||||||
|
{ width: '20%' },
|
||||||
|
{ width: '25%' },
|
||||||
|
{ width: '25%' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : positions.length === 0 && !isFetching ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Тикер
|
||||||
|
</th>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Название
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Количество
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Цена
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Стоимость
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((position) => (
|
||||||
|
<tr
|
||||||
|
key={
|
||||||
|
position.positionUid ||
|
||||||
|
position.instrumentUid ||
|
||||||
|
position.ticker ||
|
||||||
|
position.figi
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<PositionTicker position={position} />
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
{position.name || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatQuantity(position.quantity)}
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatMoney(position.currentPrice)}
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatMoney(position.currentValue)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{isFetching && (
|
||||||
|
<div className="table-loading-overlay">
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Загрузка страницы {pageNumber}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrokerPositionsPageProps = {
|
||||||
|
type: 'share' | 'bond';
|
||||||
|
title: 'Акции' | 'Облигации';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||||||
|
const { accountId } = useBrokerAccountContext();
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
|
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 (
|
||||||
|
<section aria-labelledby={`broker-${type}-heading`}>
|
||||||
|
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p role="alert">
|
||||||
|
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrokerPositionTable
|
||||||
|
title={title}
|
||||||
|
page={positions.data}
|
||||||
|
isLoading={positions.isLoading}
|
||||||
|
isFetching={positions.isFetching}
|
||||||
|
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||||||
|
pageNumber={cursorStack.length + 1}
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,178 +1 @@
|
|||||||
import { Link } from 'react-router-dom';
|
export { BrokerAccountOverviewPage } from '../broker-account';
|
||||||
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 (
|
|
||||||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
|
||||||
<div className="broker-overview__card">
|
|
||||||
<span className="broker-overview__label">Стоимость портфеля</span>
|
|
||||||
<strong className="broker-overview__total">
|
|
||||||
{formatMoney(portfolio.totals.portfolio)}
|
|
||||||
</strong>
|
|
||||||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
|
||||||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
|
||||||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="broker-overview__card">
|
|
||||||
<span className="broker-overview__label">Денежный остаток</span>
|
|
||||||
{portfolio.cash.length === 0 ? (
|
|
||||||
<span>Нет денежных остатков</span>
|
|
||||||
) : (
|
|
||||||
<ul className="broker-overview__cash">
|
|
||||||
{portfolio.cash.map((money, index) => (
|
|
||||||
<li key={`${money.currency}-${index}`}>
|
|
||||||
<span>{money.currency}</span>
|
|
||||||
<strong>{formatMoney(money)}</strong>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
|
||||||
{cards.map((card) => (
|
|
||||||
<Link
|
|
||||||
className="broker-overview__card broker-overview__asset-link"
|
|
||||||
key={card.label}
|
|
||||||
to={card.path}
|
|
||||||
>
|
|
||||||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
|
||||||
<span>
|
|
||||||
{card.count} {card.countLabel}
|
|
||||||
</span>
|
|
||||||
<span>{formatMoney(card.value)}</span>
|
|
||||||
<span>
|
|
||||||
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrokerOverviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
|
||||||
<div className="broker-overview__summary">
|
|
||||||
{[1, 2].map((item) => (
|
|
||||||
<div className="broker-overview__card" key={item}>
|
|
||||||
<SkeletonBlock height={16} width="45%" />
|
|
||||||
<SkeletonBlock height={28} width="70%" />
|
|
||||||
<SkeletonBlock height={16} width="55%" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="broker-allocation">
|
|
||||||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
|
||||||
<SkeletonBlock height={80} width="60%" />
|
|
||||||
</div>
|
|
||||||
<div className="broker-overview__assets">
|
|
||||||
{[1, 2].map((item) => (
|
|
||||||
<div className="broker-overview__card" key={item}>
|
|
||||||
<SkeletonBlock height={20} width="35%" />
|
|
||||||
<SkeletonBlock height={16} width="55%" />
|
|
||||||
<SkeletonBlock height={16} width="70%" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerAccountOverviewPage() {
|
|
||||||
const { accountId, portfolio } = useBrokerAccountContext();
|
|
||||||
const operations = useBrokerOperations(accountId, { limit: 5 });
|
|
||||||
|
|
||||||
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
|
||||||
if (portfolio.error || !portfolio.data) {
|
|
||||||
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="broker-overview">
|
|
||||||
<BrokerSummary portfolio={portfolio.data} />
|
|
||||||
<BrokerAllocationChart portfolio={portfolio.data} />
|
|
||||||
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
|
||||||
{operations.error ? (
|
|
||||||
<p role="alert">Не удалось загрузить последние операции</p>
|
|
||||||
) : (
|
|
||||||
<BrokerOperationsTable
|
|
||||||
title="Последние операции"
|
|
||||||
headerAction={
|
|
||||||
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
|
||||||
}
|
|
||||||
emptyMessage="Операций с начала текущего года нет"
|
|
||||||
isLoading={operations.isLoading}
|
|
||||||
isFetching={operations.isFetching}
|
|
||||||
page={operations.data}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,43 +1 @@
|
|||||||
import type { BrokerAllocationItem } from './brokerAllocation';
|
export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart';
|
||||||
|
|
||||||
export function BrokerAllocationBar({
|
|
||||||
items,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
items: BrokerAllocationItem[];
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const positiveItems = items.filter((item) => item.value > 0);
|
|
||||||
|
|
||||||
if (positiveItems.length === 0) {
|
|
||||||
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="broker-allocation-bar">
|
|
||||||
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
|
||||||
{positiveItems.map((item) => (
|
|
||||||
<span
|
|
||||||
key={item.key}
|
|
||||||
className="broker-allocation-bar__segment"
|
|
||||||
style={{ width: `${item.percent}%`, background: item.color }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
|
||||||
{positiveItems.map((item) => (
|
|
||||||
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
|
||||||
<span
|
|
||||||
className="broker-allocation-bar__swatch"
|
|
||||||
style={{ background: item.color }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{item.percent.toFixed(0)}%</strong>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,89 +1 @@
|
|||||||
import type { BrokerPortfolio } from '../../api/responses';
|
export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart';
|
||||||
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 (
|
|
||||||
<figure className="broker-allocation">
|
|
||||||
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
|
||||||
<title>Структура брокерского портфеля</title>
|
|
||||||
{arcs.map((sector) => (
|
|
||||||
<circle
|
|
||||||
key={sector.key}
|
|
||||||
cx="60"
|
|
||||||
cy="60"
|
|
||||||
r={RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke={sector.color}
|
|
||||||
strokeWidth="14"
|
|
||||||
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
|
|
||||||
strokeDashoffset={sector.dashOffset}
|
|
||||||
transform="rotate(-90 60 60)"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
<figcaption>
|
|
||||||
{sectors.length === 0 ? (
|
|
||||||
<p>Нет данных для распределения</p>
|
|
||||||
) : (
|
|
||||||
<ul>
|
|
||||||
{sectors.map((sector) => (
|
|
||||||
<li key={sector.key}>
|
|
||||||
<span
|
|
||||||
className="broker-allocation__swatch"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ background: sector.color }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
|
|
||||||
{sector.percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{negative.length > 0 && (
|
|
||||||
<ul
|
|
||||||
className="broker-allocation__negative"
|
|
||||||
aria-label="Отрицательные значения распределения"
|
|
||||||
>
|
|
||||||
{negative.map((item) => (
|
|
||||||
<li key={item.key}>
|
|
||||||
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import * as operationsHook from '../../hooks/useBrokerOperations';
|
|||||||
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
|
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
|
||||||
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
|
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
|
||||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
|
||||||
import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses';
|
import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses';
|
||||||
|
import * as positionsHook from '../../entities/broker-position';
|
||||||
import { AppRoutes } from '../../routes';
|
import { AppRoutes } from '../../routes';
|
||||||
import { renderWithProviders } from '../../test/test-utils';
|
import { renderWithProviders } from '../../test/test-utils';
|
||||||
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
||||||
|
|||||||
@ -1,316 +1 @@
|
|||||||
import { useState } from 'react';
|
export { BrokerPositionsPage } from '../broker-positions';
|
||||||
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 <strong>{label}</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={path} style={{ fontWeight: 700 }}>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Предыдущая страница"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={!canGoBack || isFetching}
|
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
|
||||||
{isFetching ? (
|
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 20,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pageNumber}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Следующая страница"
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={!canGoForward || isFetching}
|
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
|
||||||
{isFetching ? (
|
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
|
||||||
<table style={tableStyle}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th align="left" style={thStyle}>
|
|
||||||
Тикер
|
|
||||||
</th>
|
|
||||||
<th align="left" style={thStyle}>
|
|
||||||
Название
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Количество
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Цена
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Стоимость
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<TableSkeleton
|
|
||||||
rows={4}
|
|
||||||
columns={[
|
|
||||||
{ width: '30%' },
|
|
||||||
{ width: '50%' },
|
|
||||||
{ width: '20%' },
|
|
||||||
{ width: '25%' },
|
|
||||||
{ width: '25%' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : positions.length === 0 && !isFetching ? (
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
|
||||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th align="left" style={thStyle}>
|
|
||||||
Тикер
|
|
||||||
</th>
|
|
||||||
<th align="left" style={thStyle}>
|
|
||||||
Название
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Количество
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Цена
|
|
||||||
</th>
|
|
||||||
<th align="right" style={thStyle}>
|
|
||||||
Стоимость
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{positions.map((position) => (
|
|
||||||
<tr
|
|
||||||
key={
|
|
||||||
position.positionUid ||
|
|
||||||
position.instrumentUid ||
|
|
||||||
position.ticker ||
|
|
||||||
position.figi
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<td style={tdStyle}>
|
|
||||||
<PositionTicker position={position} />
|
|
||||||
</td>
|
|
||||||
<td style={tdStyle}>
|
|
||||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
{position.name || '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td align="right" style={tdStyle}>
|
|
||||||
{formatQuantity(position.quantity)}
|
|
||||||
</td>
|
|
||||||
<td align="right" style={tdStyle}>
|
|
||||||
{formatMoney(position.currentPrice)}
|
|
||||||
</td>
|
|
||||||
<td align="right" style={tdStyle}>
|
|
||||||
{formatMoney(position.currentValue)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{isFetching && (
|
|
||||||
<div className="table-loading-overlay">
|
|
||||||
<div className="loading-spinner" />
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
|
||||||
Загрузка страницы {pageNumber}…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrokerPositionsPageProps = {
|
|
||||||
type: 'share' | 'bond';
|
|
||||||
title: 'Акции' | 'Облигации';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
|
||||||
const { accountId } = useBrokerAccountContext();
|
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
||||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
|
||||||
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 (
|
|
||||||
<section aria-labelledby={`broker-${type}-heading`}>
|
|
||||||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p role="alert">
|
|
||||||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BrokerPositionTable
|
|
||||||
title={title}
|
|
||||||
page={positions.data}
|
|
||||||
isLoading={positions.isLoading}
|
|
||||||
isFetching={positions.isFetching}
|
|
||||||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
|
||||||
pageNumber={cursorStack.length + 1}
|
|
||||||
onNext={handleNext}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,85 +1,5 @@
|
|||||||
import type { BrokerPortfolio } from '../../api/responses';
|
export {
|
||||||
|
buildBrokerAllocation,
|
||||||
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
|
type BrokerAllocationItem,
|
||||||
|
type BrokerAllocationKey,
|
||||||
export interface BrokerAllocationItem {
|
} from '../../entities/broker-position';
|
||||||
key: BrokerAllocationKey;
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
percent: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
|
|
||||||
|
|
||||||
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
|
||||||
{ 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<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
|
||||||
shares,
|
|
||||||
bonds,
|
|
||||||
etf,
|
|
||||||
cash,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (total <= 0) {
|
|
||||||
const negative = ALLOCATION_CONFIG.filter(
|
|
||||||
(
|
|
||||||
item,
|
|
||||||
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
|
||||||
key: Exclude<BrokerAllocationKey, 'other'>;
|
|
||||||
} => 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<BrokerAllocationKey, number> = {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,177 +1,10 @@
|
|||||||
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
export {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
getBrokerInstrumentPath,
|
||||||
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
type BrokerInstrumentLinkInput = {
|
getBrokerPositionGroup,
|
||||||
ticker: string | null;
|
isBrokerOperationType,
|
||||||
instrumentType: string | null;
|
type BrokerOperationImpact,
|
||||||
classCode: string | null;
|
type BrokerPositionGroup,
|
||||||
};
|
} from '../../entities/broker-position';
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
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<BrokerPosition, 'instrumentType'>,
|
|
||||||
): 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<BrokerOperation, 'type' | 'description'>,
|
|
||||||
): 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<BrokerOperation, 'type' | 'category' | 'payment'>,
|
|
||||||
): 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';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
||||||
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '../../../api/responses';
|
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '../../../api/responses';
|
||||||
import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation';
|
import { buildBrokerAllocation } from '../../../entities/broker-position';
|
||||||
import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar';
|
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart';
|
||||||
|
|
||||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
||||||
import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation';
|
import type { BrokerAccountsAggregate } from '../../../entities/broker-account/model/brokerAccountsOverview';
|
||||||
import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar';
|
import { buildBrokerAllocation } from '../../../entities/broker-position';
|
||||||
import type { BrokerAccountsAggregate } from '../../../pages/broker/brokerAccountsOverview';
|
import { BrokerAllocationBar } from '../../../widgets/broker-allocation-chart';
|
||||||
|
|
||||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAllocationBar, BrokerAllocationChart } from './ui/BrokerAllocationChart';
|
||||||
@ -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 <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-allocation-bar">
|
||||||
|
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.key}
|
||||||
|
className="broker-allocation-bar__segment"
|
||||||
|
style={{ width: `${item.percent}%`, background: item.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
||||||
|
<span
|
||||||
|
className="broker-allocation-bar__swatch"
|
||||||
|
style={{ background: item.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.percent.toFixed(0)}%</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<figure className="broker-allocation">
|
||||||
|
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||||||
|
<title>Структура брокерского портфеля</title>
|
||||||
|
{arcs.map((sector) => (
|
||||||
|
<circle
|
||||||
|
key={sector.key}
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth="14"
|
||||||
|
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
|
||||||
|
strokeDashoffset={sector.dashOffset}
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<figcaption>
|
||||||
|
{sectors.length === 0 ? (
|
||||||
|
<p>Нет данных для распределения</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{sectors.map((sector) => (
|
||||||
|
<li key={sector.key}>
|
||||||
|
<span
|
||||||
|
className="broker-allocation__swatch"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ background: sector.color }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
|
||||||
|
{sector.percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{negative.length > 0 && (
|
||||||
|
<ul
|
||||||
|
className="broker-allocation__negative"
|
||||||
|
aria-label="Отрицательные значения распределения"
|
||||||
|
>
|
||||||
|
{negative.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user