feat: use money for upcoming event projections
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
|
||||
export class UpcomingEventDto {
|
||||
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
|
||||
@@ -22,11 +23,8 @@ export class UpcomingEventDto {
|
||||
@ApiProperty()
|
||||
displayName: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
currency: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
amount: string | null;
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
projectedAmount: MoneyDto | null;
|
||||
}
|
||||
|
||||
export class UpcomingEventsDto {
|
||||
|
||||
@@ -94,9 +94,17 @@ describe('UpcomingEventsService', () => {
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [
|
||||
{ type: 'coupon', amount: '7.5', dateKind: 'event' },
|
||||
{ type: 'dividend', amount: '2.5', dateKind: 'registry' },
|
||||
{ type: 'offer', amount: null },
|
||||
{
|
||||
type: 'coupon',
|
||||
projectedAmount: { currency: 'RUB', value: '7.5' },
|
||||
dateKind: 'event',
|
||||
},
|
||||
{
|
||||
type: 'dividend',
|
||||
projectedAmount: { currency: 'RUB', value: '2.5' },
|
||||
dateKind: 'registry',
|
||||
},
|
||||
{ type: 'offer', projectedAmount: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -135,7 +143,9 @@ describe('UpcomingEventsService', () => {
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [{ ticker: 'SBER', amount: '1' }],
|
||||
events: [
|
||||
{ ticker: 'SBER', projectedAmount: { currency: 'RUB', value: '1' } },
|
||||
],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
|
||||
});
|
||||
@@ -170,7 +180,39 @@ describe('UpcomingEventsService', () => {
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [{ type: 'dividend', amount: '2' }],
|
||||
events: [
|
||||
{
|
||||
type: 'dividend',
|
||||
projectedAmount: { currency: 'RUB', value: '2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent projected money when the source omits currency', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'S',
|
||||
ticker: 'SBER',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getDividends.mockResolvedValue([
|
||||
{
|
||||
dividendNet: { units: 1, nano: 0 },
|
||||
recordDate: {
|
||||
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||
},
|
||||
dividendType: 'Regular Cash',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
events: [{ type: 'dividend', projectedAmount: null }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RequestContextService } from '../common/logging/request-context.service
|
||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||
import { decimal, formatDecimal, isZero, multiply } from './decimal';
|
||||
import { MoexBondEvent, MoexClient } from './moex.client';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import {
|
||||
TbankClient,
|
||||
@@ -184,9 +185,12 @@ export class UpcomingEventsService {
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
dateKind: 'registry',
|
||||
currency: event.dividendNet?.currency ?? null,
|
||||
amount: event.dividendNet
|
||||
? formatDecimal(multiply(quantity, decimal(event.dividendNet)))
|
||||
projectedAmount: event.dividendNet
|
||||
? projectedMoney(
|
||||
quantity,
|
||||
formatDecimal(decimal(event.dividendNet)),
|
||||
event.dividendNet.currency,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -204,8 +208,10 @@ export class UpcomingEventsService {
|
||||
type: event.type,
|
||||
date: event.date,
|
||||
dateKind: 'event',
|
||||
currency: 'currency' in event ? event.currency : null,
|
||||
amount: 'value' in event ? amount(quantity, event.value) : null,
|
||||
projectedAmount:
|
||||
'value' in event
|
||||
? projectedMoney(quantity, event.value, event.currency)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -226,8 +232,17 @@ function dateWindow() {
|
||||
return { from, to: end.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
function amount(quantity: ReturnType<typeof decimal>, value: string): string {
|
||||
return formatDecimal(multiply(quantity, decimalFromString(value)));
|
||||
function projectedMoney(
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
value: string,
|
||||
currency: string | undefined,
|
||||
): MoneyDto | null {
|
||||
const normalizedCurrency = currency?.trim().toUpperCase();
|
||||
if (!normalizedCurrency) return null;
|
||||
return {
|
||||
currency: normalizedCurrency,
|
||||
value: formatDecimal(multiply(quantity, decimalFromString(value))),
|
||||
};
|
||||
}
|
||||
|
||||
function decimalFromString(value: string) {
|
||||
|
||||
Reference in New Issue
Block a user