refactor(frontend): align broker account shims
This commit is contained in:
parent
55cc479abb
commit
1db1763b36
@ -38,6 +38,78 @@ function moneyValue(money: BrokerMoney | null | undefined): number {
|
|||||||
return money?.value ?? 0;
|
return money?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatBrokerCurrencyValue(value.currency, value.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'RUB',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return `+${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
return `−${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerPercent(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerSignedPercent(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatBrokerPercent(Math.abs(value));
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return `+${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
return `−${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerDate(value: string | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleDateString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||||||
|
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||||||
|
}
|
||||||
|
|
||||||
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
||||||
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
||||||
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
|
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { BrokerPortfolio } from '../../api/responses';
|
|
||||||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
|
||||||
|
|
||||||
function portfolio(
|
|
||||||
id: string,
|
|
||||||
currency: string,
|
|
||||||
total: number,
|
|
||||||
daily: number | null,
|
|
||||||
cash: number,
|
|
||||||
): BrokerPortfolio {
|
|
||||||
return {
|
|
||||||
account: {
|
|
||||||
id,
|
|
||||||
type: 'brokerage',
|
|
||||||
name: id,
|
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
|
||||||
openedAt: '2022-06-16T00:00:00.000Z',
|
|
||||||
accessLevel: null,
|
|
||||||
},
|
|
||||||
positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 },
|
|
||||||
totals: {
|
|
||||||
shares: { currency, units: '0', nano: 0, value: total * 0.5 },
|
|
||||||
bonds: { currency, units: '0', nano: 0, value: total * 0.3 },
|
|
||||||
etf: null,
|
|
||||||
currencies: { currency, units: '0', nano: 0, value: total * 0.2 },
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency, units: '0', nano: 0, value: total },
|
|
||||||
},
|
|
||||||
yields: {
|
|
||||||
expectedPercent: 10,
|
|
||||||
daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily },
|
|
||||||
dailyPercent: null,
|
|
||||||
},
|
|
||||||
cash: [{ currency, units: '0', nano: 0, value: cash }],
|
|
||||||
blockedCash: [],
|
|
||||||
asOf: '2026-06-19T10:00:00.000Z',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('aggregateBrokerAccounts', () => {
|
|
||||||
it('sums comparable portfolios and uses the specified daily percent formula', () => {
|
|
||||||
const result = aggregateBrokerAccounts([
|
|
||||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
|
||||||
portfolio('b', 'RUB', 2_200, 200, 300),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.portfolios).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
currency: 'RUB',
|
|
||||||
total: 3_300,
|
|
||||||
daily: 300,
|
|
||||||
dailyPercent: 10,
|
|
||||||
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps different currencies separate', () => {
|
|
||||||
const result = aggregateBrokerAccounts([
|
|
||||||
portfolio('rub', 'RUB', 1_100, 100, 200),
|
|
||||||
portfolio('usd', 'USD', 550, 50, 25),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
|
|
||||||
{ currency: 'RUB', total: 1_100 },
|
|
||||||
{ currency: 'USD', total: 550 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not expose a daily percent when one account lacks daily data', () => {
|
|
||||||
const result = aggregateBrokerAccounts([
|
|
||||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
|
||||||
portfolio('b', 'RUB', 2_000, null, 300),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not expose a daily percent when start of day is non-positive', () => {
|
|
||||||
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]);
|
|
||||||
|
|
||||||
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps negative residual other allocation to zero', () => {
|
|
||||||
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100);
|
|
||||||
overAllocated.totals.shares!.value = 700;
|
|
||||||
overAllocated.totals.bonds!.value = 400;
|
|
||||||
overAllocated.totals.currencies!.value = 100;
|
|
||||||
|
|
||||||
const result = aggregateBrokerAccounts([overAllocated]);
|
|
||||||
|
|
||||||
expect(result.portfolios[0].allocation).toEqual({
|
|
||||||
shares: 700,
|
|
||||||
bonds: 400,
|
|
||||||
etf: 0,
|
|
||||||
cash: 100,
|
|
||||||
other: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty summaries for empty or unsupported portfolios', () => {
|
|
||||||
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100);
|
|
||||||
missingTotal.totals.portfolio = null;
|
|
||||||
|
|
||||||
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] });
|
|
||||||
expect(aggregateBrokerAccounts([missingTotal])).toEqual({
|
|
||||||
portfolios: [],
|
|
||||||
cash: [{ currency: 'RUB', value: 100 }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('groups cash separately by currency', () => {
|
|
||||||
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100);
|
|
||||||
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 });
|
|
||||||
|
|
||||||
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
|
|
||||||
{ currency: 'RUB', value: 100 },
|
|
||||||
{ currency: 'USD', value: 25 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user