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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
||||
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