feat: add broker account display models
This commit is contained in:
parent
5cb51e44f2
commit
2462f2122c
115
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal file
115
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
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('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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
57
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
if (total <= 0) return { total, sectors: [], negative: [] };
|
||||||
|
|
||||||
|
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 values: Record<BrokerAllocationKey, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
other: total - shares - bonds - etf - cash,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,10 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
||||||
import {
|
import {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
getBrokerPositionGroup,
|
getBrokerPositionGroup,
|
||||||
|
isBrokerOperationType,
|
||||||
} from './brokerDisplay';
|
} from './brokerDisplay';
|
||||||
|
|
||||||
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
||||||
@ -37,6 +39,7 @@ function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
|||||||
type: 'OPERATION_TYPE_UNSPECIFIED',
|
type: 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
category: 'other',
|
category: 'other',
|
||||||
description: null,
|
description: null,
|
||||||
|
name: null,
|
||||||
state: null,
|
state: null,
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
figi: null,
|
figi: null,
|
||||||
@ -116,6 +119,32 @@ describe('broker display helpers', () => {
|
|||||||
).toBe('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('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', () => {
|
it('classifies operations by portfolio impact', () => {
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
|
|||||||
@ -94,6 +94,16 @@ const OPERATION_TYPE_LABELS: Record<string, string> = {
|
|||||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BROKER_OPERATION_TYPE_OPTIONS = Object.entries(OPERATION_TYPE_LABELS)
|
||||||
|
.map(([value, label]) => ({ 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(
|
export function getBrokerPositionGroup(
|
||||||
position: Pick<BrokerPosition, 'instrumentType'>,
|
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||||
): BrokerPositionGroup {
|
): BrokerPositionGroup {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user