test: remove legacy broker position duplicates
This commit is contained in:
parent
8e578e7a91
commit
09213624c3
@ -1,144 +0,0 @@
|
|||||||
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' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user