feat: add actual payouts and filter-as-draft UX to broker events calendar
- Backend: actual events from T-Bank operations, types filter, split forecast/actual summary - Frontend: draft/applied filters with multi-select types, status column (Факт/Прогноз), green actual amounts - Docs: update spec, plan, tasks
This commit is contained in:
parent
c71ba090ad
commit
f7dc338719
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Matches } from 'class-validator';
|
import { IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
export class BrokerEventsQueryDto {
|
export class BrokerEventsQueryDto {
|
||||||
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' })
|
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' })
|
||||||
@ -9,4 +9,15 @@ export class BrokerEventsQueryDto {
|
|||||||
@ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' })
|
@ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' })
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
|
||||||
to!: string;
|
to!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
example: 'dividend,coupon,maturity,offer',
|
||||||
|
description: 'Comma-separated event types to include',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^(dividend|coupon|maturity|offer)(,(dividend|coupon|maturity|offer))*$/, {
|
||||||
|
message: 'types must be a comma-separated list of known event types',
|
||||||
|
})
|
||||||
|
types?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
|
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
|
||||||
|
const eventSources = ['forecast', 'actual'] as const;
|
||||||
const eventCategories = ['cashflow', 'corporate'] as const;
|
const eventCategories = ['cashflow', 'corporate'] as const;
|
||||||
const instrumentTypes = ['share', 'bond', 'other'] as const;
|
const instrumentTypes = ['share', 'bond', 'other'] as const;
|
||||||
|
|
||||||
@ -11,6 +12,9 @@ export class BrokerEventItemDto {
|
|||||||
@ApiProperty({ enum: eventTypes })
|
@ApiProperty({ enum: eventTypes })
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: eventSources })
|
||||||
|
source!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: eventCategories })
|
@ApiProperty({ enum: eventCategories })
|
||||||
category!: string;
|
category!: string;
|
||||||
|
|
||||||
@ -41,11 +45,14 @@ export class BrokerEventItemDto {
|
|||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
estimatedAmount!: number | null;
|
estimatedAmount!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
actualAmount!: number | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
currency!: string | null;
|
currency!: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ nullable: true })
|
||||||
estimateMode!: 'current_position';
|
estimateMode!: 'current_position' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerEventsSummaryDto {
|
export class BrokerEventsSummaryDto {
|
||||||
@ -58,6 +65,12 @@ export class BrokerEventsSummaryDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
totalEstimatedCashflow!: number;
|
totalEstimatedCashflow!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualCashflow!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
forecastEstimatedCashflow!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
dividendsTotal!: number;
|
dividendsTotal!: number;
|
||||||
|
|
||||||
@ -66,6 +79,15 @@ export class BrokerEventsSummaryDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
principalRepaymentTotal!: number;
|
principalRepaymentTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualDividendsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualCouponsTotal!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actualPrincipalRepaymentTotal!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerEventsDataDto {
|
export class BrokerEventsDataDto {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { CacheService } from '../../cache/cache.service';
|
|||||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
import { MoexClientService } from '../../moex-client/moex-client.service';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { BrokerEventsService } from './broker-events.service';
|
import { BrokerEventsService } from './broker-events.service';
|
||||||
|
import { BrokerOperationsService } from './broker-operations.service';
|
||||||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
import { BrokerPortfolioService } from './broker-portfolio.service';
|
||||||
|
|
||||||
describe('BrokerEventsService', () => {
|
describe('BrokerEventsService', () => {
|
||||||
@ -12,6 +13,7 @@ describe('BrokerEventsService', () => {
|
|||||||
getDividends: vi.fn(),
|
getDividends: vi.fn(),
|
||||||
getBondPositionDataBatch: vi.fn(),
|
getBondPositionDataBatch: vi.fn(),
|
||||||
} as unknown as MoexClientService;
|
} as unknown as MoexClientService;
|
||||||
|
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
const acc1 = {
|
const acc1 = {
|
||||||
@ -40,10 +42,10 @@ describe('BrokerEventsService', () => {
|
|||||||
it('throws 404 for missing account', async () => {
|
it('throws 404 for missing account', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
await expect(service.getEvents('missing', '2026-06-01', '2026-07-01')).rejects.toThrow(
|
await expect(
|
||||||
NotFoundException,
|
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
|
||||||
);
|
).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty events for account with no positions', async () => {
|
it('returns empty events for account with no positions', async () => {
|
||||||
@ -54,13 +56,20 @@ describe('BrokerEventsService', () => {
|
|||||||
instruments: new Map(),
|
instruments: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-01', '2026-07-01');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
|
||||||
|
|
||||||
expect(result.data.items).toEqual([]);
|
expect(result.data.items).toEqual([]);
|
||||||
expect(result.data.summary.eventCount).toBe(0);
|
expect(result.data.summary.eventCount).toBe(0);
|
||||||
expect(result.data.summary.nearestEventDate).toBeNull();
|
expect(result.data.summary.nearestEventDate).toBeNull();
|
||||||
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
|
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
|
||||||
|
expect(result.data.summary.actualCashflow).toBe(0);
|
||||||
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds dividend events from share positions in date range', async () => {
|
it('builds dividend events from share positions in date range', async () => {
|
||||||
@ -101,13 +110,20 @@ describe('BrokerEventsService', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
||||||
|
|
||||||
expect(result.data.items).toHaveLength(1);
|
expect(result.data.items).toHaveLength(1);
|
||||||
expect(result.data.items[0].type).toBe('dividend');
|
expect(result.data.items[0].type).toBe('dividend');
|
||||||
expect(result.data.items[0].eventDate).toBe('2026-06-25');
|
expect(result.data.items[0].eventDate).toBe('2026-06-25');
|
||||||
expect(result.data.items[0].estimatedAmount).toBe(335);
|
expect(result.data.items[0].estimatedAmount).toBe(335);
|
||||||
|
expect(result.data.items[0].actualAmount).toBeNull();
|
||||||
|
expect(result.data.items[0].source).toBe('forecast');
|
||||||
expect(result.data.items[0].currency).toBe('RUB');
|
expect(result.data.items[0].currency).toBe('RUB');
|
||||||
expect(result.data.items[0].name).toBe('Sberbank');
|
expect(result.data.items[0].name).toBe('Sberbank');
|
||||||
});
|
});
|
||||||
@ -148,8 +164,13 @@ describe('BrokerEventsService', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
||||||
|
|
||||||
expect(result.data.items).toHaveLength(3);
|
expect(result.data.items).toHaveLength(3);
|
||||||
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
|
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
|
||||||
@ -188,8 +209,13 @@ describe('BrokerEventsService', () => {
|
|||||||
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
|
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
||||||
|
|
||||||
expect(result.data.items).toHaveLength(1);
|
expect(result.data.items).toHaveLength(1);
|
||||||
expect(result.data.items[0].ticker).toBe('GOOD');
|
expect(result.data.items[0].ticker).toBe('GOOD');
|
||||||
@ -213,8 +239,13 @@ describe('BrokerEventsService', () => {
|
|||||||
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
|
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
||||||
|
|
||||||
expect(result.data.items).toHaveLength(1);
|
expect(result.data.items).toHaveLength(1);
|
||||||
expect(result.data.items[0].estimatedAmount).toBe(0);
|
expect(result.data.items[0].estimatedAmount).toBe(0);
|
||||||
@ -268,8 +299,13 @@ describe('BrokerEventsService', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
||||||
|
|
||||||
expect(result.data.summary.eventCount).toBe(3);
|
expect(result.data.summary.eventCount).toBe(3);
|
||||||
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
|
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
|
||||||
@ -277,6 +313,8 @@ describe('BrokerEventsService', () => {
|
|||||||
expect(result.data.summary.couponsTotal).toBe(100);
|
expect(result.data.summary.couponsTotal).toBe(100);
|
||||||
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
|
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
|
||||||
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
|
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
|
||||||
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(2400);
|
||||||
|
expect(result.data.summary.actualCashflow).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters events by inclusive date range', async () => {
|
it('filters events by inclusive date range', async () => {
|
||||||
@ -299,11 +337,155 @@ describe('BrokerEventsService', () => {
|
|||||||
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-29');
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
|
||||||
|
|
||||||
expect(result.data.items).toHaveLength(2);
|
expect(result.data.items).toHaveLength(2);
|
||||||
expect(result.data.items[0].eventDate).toBe('2026-06-20');
|
expect(result.data.items[0].eventDate).toBe('2026-06-20');
|
||||||
expect(result.data.items[1].eventDate).toBe('2026-07-29');
|
expect(result.data.items[1].eventDate).toBe('2026-07-29');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters forecast events by selected event types', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
ticker: 'SBER',
|
||||||
|
instrumentUid: 'uid-share',
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticker: 'BOND1',
|
||||||
|
instrumentUid: 'uid-bond',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantity: { units: '2', nano: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
instruments: new Map(),
|
||||||
|
});
|
||||||
|
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||||
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
|
||||||
|
]);
|
||||||
|
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||||
|
{
|
||||||
|
secid: 'BOND1',
|
||||||
|
couponValue: 50,
|
||||||
|
nextCouponDate: '2026-06-26',
|
||||||
|
matDate: '2026-06-27',
|
||||||
|
offerDate: '2026-06-28',
|
||||||
|
faceValue: 1000,
|
||||||
|
boardid: 'TQCB',
|
||||||
|
shortName: '',
|
||||||
|
price: null,
|
||||||
|
yieldToMaturity: null,
|
||||||
|
duration: null,
|
||||||
|
couponPercent: null,
|
||||||
|
accruedInt: null,
|
||||||
|
bid: null,
|
||||||
|
offer: null,
|
||||||
|
couponPeriod: null,
|
||||||
|
bondType: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', {
|
||||||
|
from: '2026-06-20',
|
||||||
|
to: '2026-07-10',
|
||||||
|
types: 'coupon,maturity',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data.items.map((event) => event.type)).toEqual(['coupon', 'maturity']);
|
||||||
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
['acc-1', '2026-06-20', '2026-07-10', 'coupon,maturity'],
|
||||||
|
expect.any(Function),
|
||||||
|
'tbankPortfolioTtl',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds actual past income events from broker operations', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
||||||
|
positions: [],
|
||||||
|
instruments: new Map(),
|
||||||
|
});
|
||||||
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
cursor: 'cur-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: 'op-1',
|
||||||
|
parentOperationId: null,
|
||||||
|
date: '2026-06-18T10:00:00.000Z',
|
||||||
|
type: 'OPERATION_TYPE_DIVIDEND',
|
||||||
|
category: 'income',
|
||||||
|
description: 'Dividend payment',
|
||||||
|
name: 'Sberbank',
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
instrumentUid: 'uid-sber',
|
||||||
|
figi: null,
|
||||||
|
ticker: 'SBER',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
instrumentType: 'share',
|
||||||
|
payment: { currency: 'RUB', units: '123', nano: 450000000, value: 123.45 },
|
||||||
|
price: null,
|
||||||
|
commission: null,
|
||||||
|
yield: null,
|
||||||
|
accruedInt: null,
|
||||||
|
quantity: null,
|
||||||
|
quantityDone: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
const result = await service.getEvents('acc-1', {
|
||||||
|
from: '2026-06-15',
|
||||||
|
to: '2026-06-20',
|
||||||
|
types: 'dividend',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', {
|
||||||
|
from: '2026-06-15T00:00:00.000Z',
|
||||||
|
to: '2026-06-20T23:59:59.999Z',
|
||||||
|
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
|
||||||
|
limit: 100,
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
});
|
||||||
|
expect(result.data.items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'actual-op-1',
|
||||||
|
type: 'dividend',
|
||||||
|
source: 'actual',
|
||||||
|
eventDate: '2026-06-18',
|
||||||
|
actualAmount: 123.45,
|
||||||
|
estimatedAmount: null,
|
||||||
|
estimateMode: null,
|
||||||
|
currency: 'RUB',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.data.summary.actualCashflow).toBe(123.45);
|
||||||
|
expect(result.data.summary.actualDividendsTotal).toBe(123.45);
|
||||||
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,27 +4,54 @@ import { MoexClientService } from '../../moex-client/moex-client.service';
|
|||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
||||||
import type {
|
import type {
|
||||||
|
BrokerOperation,
|
||||||
BrokerPortfolioEvent,
|
BrokerPortfolioEvent,
|
||||||
BrokerEventsData,
|
BrokerEventsData,
|
||||||
BrokerEventsSummary,
|
BrokerEventsSummary,
|
||||||
} from '../types/broker.types';
|
} from '../types/broker.types';
|
||||||
import type { TBankInstrument } from '../types/tbank-proto.types';
|
import type { TBankInstrument } from '../types/tbank-proto.types';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
|
import { BrokerOperationsService } from './broker-operations.service';
|
||||||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
import { BrokerPortfolioService } from './broker-portfolio.service';
|
||||||
|
|
||||||
|
type BrokerEventType = BrokerPortfolioEvent['type'];
|
||||||
|
|
||||||
|
type BrokerEventsQuery = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
types?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_EVENT_TYPES: BrokerEventType[] = ['dividend', 'coupon', 'maturity', 'offer'];
|
||||||
|
|
||||||
|
const ACTUAL_OPERATION_TYPES: Record<Exclude<BrokerEventType, 'offer'>, string[]> = {
|
||||||
|
dividend: ['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT'],
|
||||||
|
coupon: ['OPERATION_TYPE_COUPON'],
|
||||||
|
maturity: ['OPERATION_TYPE_BOND_REPAYMENT', 'OPERATION_TYPE_BOND_REPAYMENT_FULL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPERATION_EVENT_TYPES = new Map<string, Exclude<BrokerEventType, 'offer'>>(
|
||||||
|
Object.entries(ACTUAL_OPERATION_TYPES).flatMap(([eventType, operationTypes]) =>
|
||||||
|
operationTypes.map((operationType) => [
|
||||||
|
operationType,
|
||||||
|
eventType as Exclude<BrokerEventType, 'offer'>,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerEventsService {
|
export class BrokerEventsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly accountsService: BrokerAccountsService,
|
private readonly accountsService: BrokerAccountsService,
|
||||||
private readonly portfolioService: BrokerPortfolioService,
|
private readonly portfolioService: BrokerPortfolioService,
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexClient: MoexClientService,
|
||||||
|
private readonly operationsService: BrokerOperationsService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getEvents(
|
async getEvents(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
from: string,
|
query: BrokerEventsQuery,
|
||||||
to: string,
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: BrokerEventsData;
|
data: BrokerEventsData;
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
meta: { fromCache: boolean; cachedAt: string | null };
|
||||||
@ -32,10 +59,12 @@ export class BrokerEventsService {
|
|||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
|
const eventTypes = this.parseEventTypes(query.types);
|
||||||
|
const eventTypeKey = Array.from(eventTypes).join(',');
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
TBANK_CACHE_KEYS.events,
|
TBANK_CACHE_KEYS.events,
|
||||||
[accountId, from, to],
|
[accountId, query.from, query.to, eventTypeKey],
|
||||||
() => this.buildEvents(accountId, from, to),
|
() => this.buildEvents(accountId, query.from, query.to, eventTypes),
|
||||||
'tbankPortfolioTtl',
|
'tbankPortfolioTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -49,6 +78,7 @@ export class BrokerEventsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
|
eventTypes: Set<BrokerEventType>,
|
||||||
): Promise<BrokerEventsData> {
|
): Promise<BrokerEventsData> {
|
||||||
const { positions, instruments } =
|
const { positions, instruments } =
|
||||||
await this.portfolioService.getPositionsWithInstruments(accountId);
|
await this.portfolioService.getPositionsWithInstruments(accountId);
|
||||||
@ -59,22 +89,37 @@ export class BrokerEventsService {
|
|||||||
const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond');
|
const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond');
|
||||||
|
|
||||||
const shareResults = await Promise.allSettled(
|
const shareResults = await Promise.allSettled(
|
||||||
sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to)),
|
eventTypes.has('dividend')
|
||||||
|
? sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to))
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
for (const r of shareResults) {
|
for (const r of shareResults) {
|
||||||
if (r.status === 'fulfilled') items.push(...r.value);
|
if (r.status === 'fulfilled') items.push(...r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bondPositions.length > 0) {
|
if (bondPositions.length > 0) {
|
||||||
const bondEvents = await this.buildBondEvents(bondPositions, instruments, from, to);
|
const bondEvents = await this.buildBondEvents(
|
||||||
|
bondPositions,
|
||||||
|
instruments,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
eventTypes,
|
||||||
|
);
|
||||||
items.push(...bondEvents);
|
items.push(...bondEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.sort((a, b) => a.eventDate.localeCompare(b.eventDate));
|
const actualEvents = await this.buildActualEvents(accountId, from, to, eventTypes);
|
||||||
|
const actualKeys = new Set(actualEvents.map((event) => this.eventDedupKey(event)));
|
||||||
|
const deduplicatedForecasts = items.filter(
|
||||||
|
(event) => !actualKeys.has(this.eventDedupKey(event)),
|
||||||
|
);
|
||||||
|
|
||||||
const summary = this.buildSummary(items);
|
const allItems = [...actualEvents, ...deduplicatedForecasts];
|
||||||
|
allItems.sort((a, b) => a.eventDate.localeCompare(b.eventDate));
|
||||||
|
|
||||||
return { items, summary, asOf: new Date().toISOString() };
|
const summary = this.buildSummary(allItems);
|
||||||
|
|
||||||
|
return { items: allItems, summary, asOf: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildShareEvents(
|
private async buildShareEvents(
|
||||||
@ -114,6 +159,7 @@ export class BrokerEventsService {
|
|||||||
events.push({
|
events.push({
|
||||||
id: `div-${ticker}-${d.registryCloseDate}`,
|
id: `div-${ticker}-${d.registryCloseDate}`,
|
||||||
type: 'dividend',
|
type: 'dividend',
|
||||||
|
source: 'forecast',
|
||||||
category: 'cashflow',
|
category: 'cashflow',
|
||||||
eventDate: d.registryCloseDate,
|
eventDate: d.registryCloseDate,
|
||||||
paymentDate: null,
|
paymentDate: null,
|
||||||
@ -124,6 +170,7 @@ export class BrokerEventsService {
|
|||||||
quantitySnapshot: quantity,
|
quantitySnapshot: quantity,
|
||||||
payoutPerUnit,
|
payoutPerUnit,
|
||||||
estimatedAmount,
|
estimatedAmount,
|
||||||
|
actualAmount: null,
|
||||||
currency: d.currencyId,
|
currency: d.currencyId,
|
||||||
estimateMode: 'current_position',
|
estimateMode: 'current_position',
|
||||||
});
|
});
|
||||||
@ -142,6 +189,7 @@ export class BrokerEventsService {
|
|||||||
instruments: Map<string, Partial<TBankInstrument>>,
|
instruments: Map<string, Partial<TBankInstrument>>,
|
||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
|
eventTypes: Set<BrokerEventType>,
|
||||||
): Promise<BrokerPortfolioEvent[]> {
|
): Promise<BrokerPortfolioEvent[]> {
|
||||||
const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t));
|
const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t));
|
||||||
if (secids.length === 0) return [];
|
if (secids.length === 0) return [];
|
||||||
@ -174,7 +222,12 @@ export class BrokerEventsService {
|
|||||||
const name = instrument?.name || null;
|
const name = instrument?.name || null;
|
||||||
const currency = instrument?.currency || 'RUB';
|
const currency = instrument?.currency || 'RUB';
|
||||||
|
|
||||||
if (bond.nextCouponDate && bond.nextCouponDate >= from && bond.nextCouponDate <= to) {
|
if (
|
||||||
|
eventTypes.has('coupon') &&
|
||||||
|
bond.nextCouponDate &&
|
||||||
|
bond.nextCouponDate >= from &&
|
||||||
|
bond.nextCouponDate <= to
|
||||||
|
) {
|
||||||
const payoutPerUnit = bond.couponValue;
|
const payoutPerUnit = bond.couponValue;
|
||||||
const estimatedAmount =
|
const estimatedAmount =
|
||||||
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
|
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
|
||||||
@ -182,6 +235,7 @@ export class BrokerEventsService {
|
|||||||
events.push({
|
events.push({
|
||||||
id: `coupon-${ticker}-${bond.nextCouponDate}`,
|
id: `coupon-${ticker}-${bond.nextCouponDate}`,
|
||||||
type: 'coupon',
|
type: 'coupon',
|
||||||
|
source: 'forecast',
|
||||||
category: 'cashflow',
|
category: 'cashflow',
|
||||||
eventDate: bond.nextCouponDate,
|
eventDate: bond.nextCouponDate,
|
||||||
paymentDate: null,
|
paymentDate: null,
|
||||||
@ -192,18 +246,25 @@ export class BrokerEventsService {
|
|||||||
quantitySnapshot: quantity,
|
quantitySnapshot: quantity,
|
||||||
payoutPerUnit,
|
payoutPerUnit,
|
||||||
estimatedAmount,
|
estimatedAmount,
|
||||||
|
actualAmount: null,
|
||||||
currency,
|
currency,
|
||||||
estimateMode: 'current_position',
|
estimateMode: 'current_position',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bond.matDate && bond.matDate >= from && bond.matDate <= to) {
|
if (
|
||||||
|
eventTypes.has('maturity') &&
|
||||||
|
bond.matDate &&
|
||||||
|
bond.matDate >= from &&
|
||||||
|
bond.matDate <= to
|
||||||
|
) {
|
||||||
const payoutPerUnit = bond.faceValue;
|
const payoutPerUnit = bond.faceValue;
|
||||||
const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null;
|
const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null;
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
id: `maturity-${ticker}-${bond.matDate}`,
|
id: `maturity-${ticker}-${bond.matDate}`,
|
||||||
type: 'maturity',
|
type: 'maturity',
|
||||||
|
source: 'forecast',
|
||||||
category: 'cashflow',
|
category: 'cashflow',
|
||||||
eventDate: bond.matDate,
|
eventDate: bond.matDate,
|
||||||
paymentDate: null,
|
paymentDate: null,
|
||||||
@ -214,15 +275,22 @@ export class BrokerEventsService {
|
|||||||
quantitySnapshot: quantity,
|
quantitySnapshot: quantity,
|
||||||
payoutPerUnit,
|
payoutPerUnit,
|
||||||
estimatedAmount,
|
estimatedAmount,
|
||||||
|
actualAmount: null,
|
||||||
currency,
|
currency,
|
||||||
estimateMode: 'current_position',
|
estimateMode: 'current_position',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bond.offerDate && bond.offerDate >= from && bond.offerDate <= to) {
|
if (
|
||||||
|
eventTypes.has('offer') &&
|
||||||
|
bond.offerDate &&
|
||||||
|
bond.offerDate >= from &&
|
||||||
|
bond.offerDate <= to
|
||||||
|
) {
|
||||||
events.push({
|
events.push({
|
||||||
id: `offer-${ticker}-${bond.offerDate}`,
|
id: `offer-${ticker}-${bond.offerDate}`,
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
|
source: 'forecast',
|
||||||
category: 'corporate',
|
category: 'corporate',
|
||||||
eventDate: bond.offerDate,
|
eventDate: bond.offerDate,
|
||||||
paymentDate: null,
|
paymentDate: null,
|
||||||
@ -233,6 +301,7 @@ export class BrokerEventsService {
|
|||||||
quantitySnapshot: quantity,
|
quantitySnapshot: quantity,
|
||||||
payoutPerUnit: null,
|
payoutPerUnit: null,
|
||||||
estimatedAmount: null,
|
estimatedAmount: null,
|
||||||
|
actualAmount: null,
|
||||||
currency,
|
currency,
|
||||||
estimateMode: 'current_position',
|
estimateMode: 'current_position',
|
||||||
});
|
});
|
||||||
@ -244,20 +313,119 @@ export class BrokerEventsService {
|
|||||||
|
|
||||||
private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary {
|
private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary {
|
||||||
const cashflowEvents = items.filter((e) => e.category === 'cashflow');
|
const cashflowEvents = items.filter((e) => e.category === 'cashflow');
|
||||||
|
const forecastEvents = cashflowEvents.filter((e) => e.source === 'forecast');
|
||||||
|
const actualEvents = cashflowEvents.filter((e) => e.source === 'actual');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventCount: items.length,
|
eventCount: items.length,
|
||||||
nearestEventDate: items.length > 0 ? items[0].eventDate : null,
|
nearestEventDate: items.length > 0 ? items[0].eventDate : null,
|
||||||
totalEstimatedCashflow: cashflowEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
totalEstimatedCashflow: forecastEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
||||||
dividendsTotal: cashflowEvents
|
forecastEstimatedCashflow: forecastEvents.reduce(
|
||||||
|
(sum, e) => sum + (e.estimatedAmount ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
actualCashflow: actualEvents.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0),
|
||||||
|
dividendsTotal: forecastEvents
|
||||||
.filter((e) => e.type === 'dividend')
|
.filter((e) => e.type === 'dividend')
|
||||||
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
||||||
couponsTotal: cashflowEvents
|
couponsTotal: forecastEvents
|
||||||
.filter((e) => e.type === 'coupon')
|
.filter((e) => e.type === 'coupon')
|
||||||
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
||||||
principalRepaymentTotal: cashflowEvents
|
principalRepaymentTotal: forecastEvents
|
||||||
.filter((e) => e.type === 'maturity')
|
.filter((e) => e.type === 'maturity')
|
||||||
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
|
||||||
|
actualDividendsTotal: actualEvents
|
||||||
|
.filter((e) => e.type === 'dividend')
|
||||||
|
.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0),
|
||||||
|
actualCouponsTotal: actualEvents
|
||||||
|
.filter((e) => e.type === 'coupon')
|
||||||
|
.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0),
|
||||||
|
actualPrincipalRepaymentTotal: actualEvents
|
||||||
|
.filter((e) => e.type === 'maturity')
|
||||||
|
.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildActualEvents(
|
||||||
|
accountId: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
eventTypes: Set<BrokerEventType>,
|
||||||
|
): Promise<BrokerPortfolioEvent[]> {
|
||||||
|
const operationTypes = this.actualOperationTypes(eventTypes);
|
||||||
|
if (operationTypes.length === 0) return [];
|
||||||
|
|
||||||
|
const page = await this.operationsService.getOperations(accountId, {
|
||||||
|
from: `${from}T00:00:00.000Z`,
|
||||||
|
to: `${to}T23:59:59.999Z`,
|
||||||
|
operationTypes: operationTypes.join(','),
|
||||||
|
limit: 100,
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
});
|
||||||
|
|
||||||
|
return page.data.items.flatMap((operation) => {
|
||||||
|
const eventType = OPERATION_EVENT_TYPES.get(operation.type);
|
||||||
|
if (!eventType || !eventTypes.has(eventType) || !operation.date) return [];
|
||||||
|
|
||||||
|
const eventDate = operation.date.slice(0, 10);
|
||||||
|
if (eventDate < from || eventDate > to) return [];
|
||||||
|
|
||||||
|
return [this.mapActualOperation(operation, eventType, eventDate)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapActualOperation(
|
||||||
|
operation: BrokerOperation,
|
||||||
|
type: Exclude<BrokerEventType, 'offer'>,
|
||||||
|
eventDate: string,
|
||||||
|
): BrokerPortfolioEvent {
|
||||||
|
return {
|
||||||
|
id: `actual-${operation.id || operation.cursor || `${type}-${eventDate}`}`,
|
||||||
|
type,
|
||||||
|
source: 'actual',
|
||||||
|
category: 'cashflow',
|
||||||
|
eventDate,
|
||||||
|
paymentDate: eventDate,
|
||||||
|
ticker: operation.ticker,
|
||||||
|
name: operation.name || operation.description,
|
||||||
|
instrumentUid: operation.instrumentUid,
|
||||||
|
instrumentType: this.mapInstrumentType(operation.instrumentType),
|
||||||
|
quantitySnapshot: operation.quantityDone ?? operation.quantity,
|
||||||
|
payoutPerUnit: null,
|
||||||
|
estimatedAmount: null,
|
||||||
|
actualAmount: operation.payment?.value ?? null,
|
||||||
|
currency: operation.payment?.currency ?? null,
|
||||||
|
estimateMode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEventTypes(types: string | undefined): Set<BrokerEventType> {
|
||||||
|
if (!types) return new Set(ALL_EVENT_TYPES);
|
||||||
|
|
||||||
|
const parsed = types
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value): value is BrokerEventType =>
|
||||||
|
ALL_EVENT_TYPES.includes(value as BrokerEventType),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Set(parsed.length > 0 ? parsed : ALL_EVENT_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private actualOperationTypes(eventTypes: Set<BrokerEventType>): string[] {
|
||||||
|
return Array.from(eventTypes).flatMap((type) =>
|
||||||
|
type === 'offer' ? [] : ACTUAL_OPERATION_TYPES[type],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapInstrumentType(instrumentType: string | null): 'share' | 'bond' | 'other' {
|
||||||
|
const normalized = instrumentType?.toLowerCase();
|
||||||
|
if (normalized === 'share') return 'share';
|
||||||
|
if (normalized === 'bond') return 'bond';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventDedupKey(event: BrokerPortfolioEvent): string {
|
||||||
|
return `${event.type}:${event.ticker ?? event.instrumentUid ?? event.name ?? ''}:${event.eventDate}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,9 +68,14 @@ describe('TBankController', () => {
|
|||||||
eventCount: 0,
|
eventCount: 0,
|
||||||
nearestEventDate: null,
|
nearestEventDate: null,
|
||||||
totalEstimatedCashflow: 0,
|
totalEstimatedCashflow: 0,
|
||||||
|
actualCashflow: 0,
|
||||||
|
forecastEstimatedCashflow: 0,
|
||||||
dividendsTotal: 0,
|
dividendsTotal: 0,
|
||||||
couponsTotal: 0,
|
couponsTotal: 0,
|
||||||
principalRepaymentTotal: 0,
|
principalRepaymentTotal: 0,
|
||||||
|
actualDividendsTotal: 0,
|
||||||
|
actualCouponsTotal: 0,
|
||||||
|
actualPrincipalRepaymentTotal: 0,
|
||||||
},
|
},
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
asOf: '2026-06-22T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
@ -80,9 +85,10 @@ describe('TBankController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync);
|
||||||
const response = await controller.getEvents('acc-1', { from: '2026-06-22', to: '2026-07-29' });
|
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||||
|
const response = await controller.getEvents('acc-1', query);
|
||||||
|
|
||||||
expect(events.getEvents).toHaveBeenCalledWith('acc-1', '2026-06-22', '2026-07-29');
|
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiResponse);
|
||||||
expect(response.data).toEqual(eventsData);
|
expect(response.data).toEqual(eventsData);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -77,10 +77,10 @@ export class TBankController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/events')
|
@Get('accounts/:accountId/events')
|
||||||
@ApiOperation({ summary: 'Get upcoming events and estimated cashflow for a broker account' })
|
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
|
||||||
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
|
||||||
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
|
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
|
||||||
const result = await this.brokerEventsService.getEvents(accountId, query.from, query.to);
|
const result = await this.brokerEventsService.getEvents(accountId, query);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -106,6 +106,7 @@ export type BrokerOperationsPage = {
|
|||||||
export type BrokerPortfolioEvent = {
|
export type BrokerPortfolioEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
||||||
|
source: 'forecast' | 'actual';
|
||||||
category: 'cashflow' | 'corporate';
|
category: 'cashflow' | 'corporate';
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
paymentDate: string | null;
|
paymentDate: string | null;
|
||||||
@ -116,17 +117,23 @@ export type BrokerPortfolioEvent = {
|
|||||||
quantitySnapshot: number | null;
|
quantitySnapshot: number | null;
|
||||||
payoutPerUnit: number | null;
|
payoutPerUnit: number | null;
|
||||||
estimatedAmount: number | null;
|
estimatedAmount: number | null;
|
||||||
|
actualAmount: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
estimateMode: 'current_position';
|
estimateMode: 'current_position' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrokerEventsSummary = {
|
export type BrokerEventsSummary = {
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
nearestEventDate: string | null;
|
nearestEventDate: string | null;
|
||||||
totalEstimatedCashflow: number;
|
totalEstimatedCashflow: number;
|
||||||
|
actualCashflow: number;
|
||||||
|
forecastEstimatedCashflow: number;
|
||||||
dividendsTotal: number;
|
dividendsTotal: number;
|
||||||
couponsTotal: number;
|
couponsTotal: number;
|
||||||
principalRepaymentTotal: number;
|
principalRepaymentTotal: number;
|
||||||
|
actualDividendsTotal: number;
|
||||||
|
actualCouponsTotal: number;
|
||||||
|
actualPrincipalRepaymentTotal: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrokerEventsData = {
|
export type BrokerEventsData = {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses';
|
|||||||
export type BrokerEventsQuery = {
|
export type BrokerEventsQuery = {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
types?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getBrokerEvents(
|
export function getBrokerEvents(
|
||||||
@ -15,6 +16,7 @@ export function getBrokerEvents(
|
|||||||
{
|
{
|
||||||
from: query.from,
|
from: query.from,
|
||||||
to: query.to,
|
to: query.to,
|
||||||
|
types: query.types,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,13 +81,16 @@ describe('useBrokerEvents', () => {
|
|||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29'],
|
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'],
|
||||||
mockEventsData,
|
mockEventsData,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
|
const { result } = renderHook(
|
||||||
wrapper: createWrapper(queryClient),
|
() => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }),
|
||||||
});
|
{
|
||||||
|
wrapper: createWrapper(queryClient),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.data).toBe(mockEventsData));
|
await waitFor(() => expect(result.current.data).toBe(mockEventsData));
|
||||||
expect(getBrokerEvents).not.toHaveBeenCalled();
|
expect(getBrokerEvents).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import type { BrokerEventsData } from '@/shared/api/responses';
|
|||||||
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
|
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
|
||||||
|
|
||||||
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
||||||
const { from, to } = query;
|
const { from, to, types } = query;
|
||||||
return useQuery<BrokerEventsData>({
|
return useQuery<BrokerEventsData>({
|
||||||
queryKey: ['broker', 'events', accountId, from, to],
|
queryKey: ['broker', 'events', accountId, from, to, types],
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
queryFn: async () => (await getBrokerEvents(accountId!, { from, to })).data,
|
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
|
||||||
staleTime: 300_000,
|
staleTime: 300_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React, { type ReactNode } from 'react';
|
import React, { type ReactNode } from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { BrokerEventsPage } from './BrokerEventsPage';
|
import { BrokerEventsPage } from './BrokerEventsPage';
|
||||||
@ -13,11 +14,24 @@ vi.mock('@/widgets/broker-account-layout', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSetSearchParams = vi.fn();
|
const mockSetSearchParams = vi.fn();
|
||||||
|
let currentSearchParams = new URLSearchParams();
|
||||||
vi.mock('react-router-dom', () => ({
|
vi.mock('react-router-dom', () => ({
|
||||||
useSearchParams: () => [new URLSearchParams(), mockSetSearchParams],
|
useSearchParams: () => [currentSearchParams, mockSetSearchParams],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@moex-vibe/design-system', () => ({
|
vi.mock('@moex-vibe/design-system', () => ({
|
||||||
|
Button: ({ children, onClick, disabled }: any) => (
|
||||||
|
<button type="button" onClick={onClick} disabled={disabled}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
Checkbox: ({ label, checked, onChange }: any) => (
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
Chip: ({ label }: any) => <span>{label}</span>,
|
||||||
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||||
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||||
TextField: ({ label, value, onChange, ...props }: any) => (
|
TextField: ({ label, value, onChange, ...props }: any) => (
|
||||||
@ -44,36 +58,70 @@ const mockData = {
|
|||||||
summary: {
|
summary: {
|
||||||
eventCount: 3,
|
eventCount: 3,
|
||||||
totalEstimatedCashflow: 450,
|
totalEstimatedCashflow: 450,
|
||||||
|
actualCashflow: 0,
|
||||||
|
forecastEstimatedCashflow: 450,
|
||||||
nearestEventDate: '2026-06-25',
|
nearestEventDate: '2026-06-25',
|
||||||
currency: 'RUB',
|
dividendsTotal: 150,
|
||||||
|
couponsTotal: 36.9,
|
||||||
|
principalRepaymentTotal: 1000,
|
||||||
|
actualDividendsTotal: 0,
|
||||||
|
actualCouponsTotal: 0,
|
||||||
|
actualPrincipalRepaymentTotal: 0,
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'ev-1',
|
id: 'ev-1',
|
||||||
type: 'dividend',
|
type: 'dividend',
|
||||||
|
source: 'forecast',
|
||||||
|
category: 'cashflow',
|
||||||
ticker: 'SBER',
|
ticker: 'SBER',
|
||||||
name: 'Сбер Банк',
|
name: 'Сбер Банк',
|
||||||
eventDate: '2026-06-25',
|
eventDate: '2026-06-25',
|
||||||
|
paymentDate: null,
|
||||||
|
instrumentUid: 'uid-sber',
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantitySnapshot: 10,
|
||||||
|
payoutPerUnit: 15,
|
||||||
estimatedAmount: 150,
|
estimatedAmount: 150,
|
||||||
|
actualAmount: null,
|
||||||
currency: 'RUB' as const,
|
currency: 'RUB' as const,
|
||||||
|
estimateMode: 'current_position',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ev-2',
|
id: 'ev-2',
|
||||||
type: 'coupon',
|
type: 'coupon',
|
||||||
|
source: 'forecast',
|
||||||
|
category: 'cashflow',
|
||||||
ticker: 'SU26238RMFS5',
|
ticker: 'SU26238RMFS5',
|
||||||
name: 'ОФЗ 26238',
|
name: 'ОФЗ 26238',
|
||||||
eventDate: '2026-06-27',
|
eventDate: '2026-06-27',
|
||||||
|
paymentDate: null,
|
||||||
|
instrumentUid: 'uid-bond',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantitySnapshot: 1,
|
||||||
|
payoutPerUnit: 36.9,
|
||||||
estimatedAmount: 36.9,
|
estimatedAmount: 36.9,
|
||||||
|
actualAmount: null,
|
||||||
currency: 'RUB' as const,
|
currency: 'RUB' as const,
|
||||||
|
estimateMode: 'current_position',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ev-3',
|
id: 'ev-3',
|
||||||
type: 'maturity',
|
type: 'maturity',
|
||||||
|
source: 'actual',
|
||||||
|
category: 'cashflow',
|
||||||
ticker: 'VTBR',
|
ticker: 'VTBR',
|
||||||
name: 'ВТБ',
|
name: 'ВТБ',
|
||||||
eventDate: '2026-06-30',
|
eventDate: '2026-06-30',
|
||||||
estimatedAmount: 1000,
|
paymentDate: '2026-06-30',
|
||||||
|
instrumentUid: 'uid-vtbr',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantitySnapshot: null,
|
||||||
|
payoutPerUnit: null,
|
||||||
|
estimatedAmount: null,
|
||||||
|
actualAmount: 1000,
|
||||||
currency: 'RUB' as const,
|
currency: 'RUB' as const,
|
||||||
|
estimateMode: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -81,6 +129,7 @@ const mockData = {
|
|||||||
describe('BrokerEventsPage', () => {
|
describe('BrokerEventsPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
currentSearchParams = new URLSearchParams();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders loading state', () => {
|
it('renders loading state', () => {
|
||||||
@ -153,8 +202,15 @@ describe('BrokerEventsPage', () => {
|
|||||||
summary: {
|
summary: {
|
||||||
eventCount: 0,
|
eventCount: 0,
|
||||||
totalEstimatedCashflow: 0,
|
totalEstimatedCashflow: 0,
|
||||||
|
actualCashflow: 0,
|
||||||
|
forecastEstimatedCashflow: 0,
|
||||||
nearestEventDate: null,
|
nearestEventDate: null,
|
||||||
currency: 'RUB',
|
dividendsTotal: 0,
|
||||||
|
couponsTotal: 0,
|
||||||
|
principalRepaymentTotal: 0,
|
||||||
|
actualDividendsTotal: 0,
|
||||||
|
actualCouponsTotal: 0,
|
||||||
|
actualPrincipalRepaymentTotal: 0,
|
||||||
},
|
},
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
@ -182,8 +238,15 @@ describe('BrokerEventsPage', () => {
|
|||||||
summary: {
|
summary: {
|
||||||
eventCount: 0,
|
eventCount: 0,
|
||||||
totalEstimatedCashflow: 0,
|
totalEstimatedCashflow: 0,
|
||||||
|
actualCashflow: 0,
|
||||||
|
forecastEstimatedCashflow: 0,
|
||||||
nearestEventDate: null,
|
nearestEventDate: null,
|
||||||
currency: 'RUB',
|
dividendsTotal: 0,
|
||||||
|
couponsTotal: 0,
|
||||||
|
principalRepaymentTotal: 0,
|
||||||
|
actualDividendsTotal: 0,
|
||||||
|
actualCouponsTotal: 0,
|
||||||
|
actualPrincipalRepaymentTotal: 0,
|
||||||
},
|
},
|
||||||
items: [],
|
items: [],
|
||||||
}),
|
}),
|
||||||
@ -262,12 +325,65 @@ describe('BrokerEventsPage', () => {
|
|||||||
expect(screen.getByText('Событий')).toBeInTheDocument();
|
expect(screen.getByText('Событий')).toBeInTheDocument();
|
||||||
expect(screen.getByText('3')).toBeInTheDocument();
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Ближайшее')).toBeInTheDocument();
|
expect(screen.getByText('Ближайшее')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Денежный поток')).toBeInTheDocument();
|
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument();
|
expect(screen.getByText('Дивиденд')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Купон')).toBeInTheDocument();
|
expect(screen.getByText('Купон')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Погашение')).toBeInTheDocument();
|
expect(screen.getByText('Погашение')).toBeInTheDocument();
|
||||||
expect(screen.getByText('SBER')).toBeInTheDocument();
|
expect(screen.getByText('SBER')).toBeInTheDocument();
|
||||||
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
|
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
|
||||||
expect(screen.getByText('VTBR')).toBeInTheDocument();
|
expect(screen.getByText('VTBR')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('Факт')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps date and type changes as draft until applying filters', async () => {
|
||||||
|
currentSearchParams = new URLSearchParams({
|
||||||
|
from: '2026-06-15',
|
||||||
|
to: '2026-06-29',
|
||||||
|
types: 'dividend,coupon',
|
||||||
|
});
|
||||||
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
|
data: mockData,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
isSuccess: true,
|
||||||
|
isPending: false,
|
||||||
|
dataUpdatedAt: Date.now(),
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
isFetched: true,
|
||||||
|
isFetchedAfterMount: true,
|
||||||
|
isFetching: false,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isPaused: false,
|
||||||
|
isLoadingError: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isPlaceholderData: false,
|
||||||
|
isStale: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
promise: Promise.resolve(mockData),
|
||||||
|
status: 'success',
|
||||||
|
fetchStatus: 'idle',
|
||||||
|
} as unknown as ReturnType<typeof useBrokerEvents>);
|
||||||
|
|
||||||
|
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await userEvent.clear(screen.getByLabelText('С'));
|
||||||
|
await userEvent.type(screen.getByLabelText('С'), '2026-06-10');
|
||||||
|
await userEvent.click(screen.getByLabelText('Купоны'));
|
||||||
|
|
||||||
|
expect(mockSetSearchParams).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Показать' }));
|
||||||
|
|
||||||
|
const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams;
|
||||||
|
expect(applied.get('from')).toBe('2026-06-10');
|
||||||
|
expect(applied.get('to')).toBe('2026-06-29');
|
||||||
|
expect(applied.get('types')).toBe('dividend');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,29 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { Heading, Text, TextField } from '@moex-vibe/design-system';
|
import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useBrokerEvents } from '@/entities/broker-event';
|
import { useBrokerEvents } from '@/entities/broker-event';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters';
|
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
|
const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const;
|
||||||
|
|
||||||
|
type EventType = (typeof EVENT_TYPES)[number];
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
types: EventType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [
|
||||||
|
{ value: 'dividend', label: 'Дивиденды' },
|
||||||
|
{ value: 'coupon', label: 'Купоны' },
|
||||||
|
{ value: 'maturity', label: 'Погашения' },
|
||||||
|
{ value: 'offer', label: 'Оферты' },
|
||||||
|
];
|
||||||
|
|
||||||
function eventTypeLabel(type: string): string {
|
function eventTypeLabel(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'dividend':
|
case 'dividend':
|
||||||
@ -25,29 +42,64 @@ function eventTypeLabel(type: string): string {
|
|||||||
function defaultPeriod(): { from: string; to: string } {
|
function defaultPeriod(): { from: string; to: string } {
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
return {
|
return {
|
||||||
from: now.format('YYYY-MM-DD'),
|
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTypes(value: string | null): EventType[] {
|
||||||
|
if (!value) return [...EVENT_TYPES];
|
||||||
|
|
||||||
|
const parsed = value
|
||||||
|
.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter((type): type is EventType => EVENT_TYPES.includes(type as EventType));
|
||||||
|
|
||||||
|
return parsed.length > 0 ? parsed : [...EVENT_TYPES];
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersFromSearchParams(searchParams: URLSearchParams): Filters {
|
||||||
|
const def = defaultPeriod();
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
const to = searchParams.get('to');
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from && dayjs(from).isValid() ? from : def.from,
|
||||||
|
to: to && dayjs(to).isValid() ? to : def.to,
|
||||||
|
types: parseTypes(searchParams.get('types')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersToSearchParams(filters: Filters): URLSearchParams {
|
||||||
|
const next = new URLSearchParams();
|
||||||
|
next.set('from', filters.from);
|
||||||
|
next.set('to', filters.to);
|
||||||
|
next.set('types', filters.types.join(','));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: string): string {
|
||||||
|
return source === 'actual' ? 'Факт' : 'Прогноз';
|
||||||
|
}
|
||||||
|
|
||||||
export function BrokerEventsPage() {
|
export function BrokerEventsPage() {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
|
||||||
|
filtersFromSearchParams(searchParams),
|
||||||
|
);
|
||||||
|
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
|
||||||
|
filtersFromSearchParams(searchParams),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const from = searchParams.get('from');
|
const next = filtersFromSearchParams(searchParams);
|
||||||
const to = searchParams.get('to');
|
setAppliedFilters(next);
|
||||||
if (!from || !to || !dayjs(from).isValid() || !dayjs(to).isValid()) {
|
setDraftFilters(next);
|
||||||
const def = defaultPeriod();
|
|
||||||
setSearchParams({ from: def.from, to: def.to }, { replace: true });
|
|
||||||
}
|
|
||||||
setInitialized(true);
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const from = searchParams.get('from') ?? '';
|
const from = draftFilters.from;
|
||||||
const to = searchParams.get('to') ?? '';
|
const to = draftFilters.to;
|
||||||
const period = initialized ? { from, to } : defaultPeriod();
|
|
||||||
|
|
||||||
const validFrom = dayjs(from);
|
const validFrom = dayjs(from);
|
||||||
const validTo = dayjs(to);
|
const validTo = dayjs(to);
|
||||||
@ -55,11 +107,32 @@ export function BrokerEventsPage() {
|
|||||||
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
|
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
|
||||||
? '"По" не может быть раньше "С"'
|
? '"По" не может быть раньше "С"'
|
||||||
: '';
|
: '';
|
||||||
|
const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : '';
|
||||||
|
const filterError = dateError || typeError;
|
||||||
|
|
||||||
const events = useBrokerEvents(dateError ? undefined : accountId, period);
|
const events = useBrokerEvents(filterError ? undefined : accountId, {
|
||||||
|
from: appliedFilters.from,
|
||||||
|
to: appliedFilters.to,
|
||||||
|
types: appliedFilters.types.join(','),
|
||||||
|
});
|
||||||
|
|
||||||
const ev = events.data;
|
const ev = events.data;
|
||||||
|
|
||||||
|
function toggleType(type: EventType, checked: boolean) {
|
||||||
|
setDraftFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
types: checked
|
||||||
|
? [...new Set([...current.types, type])]
|
||||||
|
: current.types.filter((t) => t !== type),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
if (filterError) return;
|
||||||
|
setAppliedFilters(draftFilters);
|
||||||
|
setSearchParams(filtersToSearchParams(draftFilters), { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-labelledby="broker-events-heading">
|
<Box component="section" aria-labelledby="broker-events-heading">
|
||||||
<Box
|
<Box
|
||||||
@ -68,15 +141,13 @@ export function BrokerEventsPage() {
|
|||||||
<Heading level={2} id="broker-events-heading">
|
<Heading level={2} id="broker-events-heading">
|
||||||
События
|
События
|
||||||
</Heading>
|
</Heading>
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'flex-end' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="С"
|
label="С"
|
||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newParams = new URLSearchParams(searchParams);
|
setDraftFilters((current) => ({ ...current, from: e.target.value }));
|
||||||
newParams.set('from', e.target.value);
|
|
||||||
setSearchParams(newParams, { replace: true });
|
|
||||||
}}
|
}}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
@ -85,14 +156,45 @@ export function BrokerEventsPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newParams = new URLSearchParams(searchParams);
|
setDraftFilters((current) => ({ ...current, to: e.target.value }));
|
||||||
newParams.set('to', e.target.value);
|
|
||||||
setSearchParams(newParams, { replace: true });
|
|
||||||
}}
|
}}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
error={!!dateError}
|
error={!!dateError}
|
||||||
helperText={dateError}
|
helperText={dateError}
|
||||||
/>
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: typeError ? 'error.main' : 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
minWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="caption" tone={typeError ? 'negative' : 'secondary'}>
|
||||||
|
Типы событий
|
||||||
|
</Text>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{EVENT_TYPE_OPTIONS.map((option) => (
|
||||||
|
<Checkbox
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
checked={draftFilters.types.includes(option.value)}
|
||||||
|
onChange={(checked) => toggleType(option.value, checked)}
|
||||||
|
error={!!typeError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{typeError && (
|
||||||
|
<Text variant="caption" tone="negative">
|
||||||
|
{typeError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button onClick={applyFilters} disabled={!!filterError}>
|
||||||
|
Показать
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -136,15 +238,23 @@ export function BrokerEventsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text variant="caption" tone="secondary">
|
<Text variant="caption" tone="secondary">
|
||||||
Денежный поток
|
Прогноз выплат
|
||||||
</Text>
|
</Text>
|
||||||
<Box sx={{ fontWeight: 700 }}>
|
<Box sx={{ fontWeight: 700 }}>
|
||||||
~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)}
|
~{formatBrokerCurrencyValue('RUB', ev.summary.forecastEstimatedCashflow)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text variant="caption" tone="muted">
|
<Text variant="caption" tone="muted">
|
||||||
оценка*
|
оценка*
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text variant="caption" tone="secondary">
|
||||||
|
Поступило
|
||||||
|
</Text>
|
||||||
|
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
|
||||||
|
+{formatBrokerCurrencyValue('RUB', ev.summary.actualCashflow)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
|
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
|
||||||
@ -177,6 +287,19 @@ export function BrokerEventsPage() {
|
|||||||
>
|
>
|
||||||
Тип
|
Тип
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="th"
|
||||||
|
sx={{
|
||||||
|
textAlign: 'left',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
p: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Статус
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component="th"
|
component="th"
|
||||||
sx={{
|
sx={{
|
||||||
@ -220,6 +343,15 @@ export function BrokerEventsPage() {
|
|||||||
>
|
>
|
||||||
{eventTypeLabel(item.type)}
|
{eventTypeLabel(item.type)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="td"
|
||||||
|
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
label={sourceLabel(item.source)}
|
||||||
|
tone={item.source === 'actual' ? 'success' : 'info'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component="td"
|
component="td"
|
||||||
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
@ -240,7 +372,16 @@ export function BrokerEventsPage() {
|
|||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.estimatedAmount != null ? (
|
{item.source === 'actual' && item.actualAmount != null ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
|
||||||
|
+{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.actualAmount)}
|
||||||
|
</Box>
|
||||||
|
<Text variant="caption" tone="secondary">
|
||||||
|
Поступило
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : item.estimatedAmount != null ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ fontWeight: 700 }}>
|
<Box sx={{ fontWeight: 700 }}>
|
||||||
~
|
~
|
||||||
|
|||||||
@ -354,6 +354,7 @@ export interface BrokerPositionsPage {
|
|||||||
export interface BrokerPortfolioEvent {
|
export interface BrokerPortfolioEvent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
||||||
|
source: 'forecast' | 'actual';
|
||||||
category: 'cashflow' | 'corporate';
|
category: 'cashflow' | 'corporate';
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
paymentDate: string | null;
|
paymentDate: string | null;
|
||||||
@ -364,17 +365,23 @@ export interface BrokerPortfolioEvent {
|
|||||||
quantitySnapshot: number | null;
|
quantitySnapshot: number | null;
|
||||||
payoutPerUnit: number | null;
|
payoutPerUnit: number | null;
|
||||||
estimatedAmount: number | null;
|
estimatedAmount: number | null;
|
||||||
|
actualAmount: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
estimateMode: 'current_position';
|
estimateMode: 'current_position' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokerEventsSummary {
|
export interface BrokerEventsSummary {
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
nearestEventDate: string | null;
|
nearestEventDate: string | null;
|
||||||
totalEstimatedCashflow: number;
|
totalEstimatedCashflow: number;
|
||||||
|
actualCashflow: number;
|
||||||
|
forecastEstimatedCashflow: number;
|
||||||
dividendsTotal: number;
|
dividendsTotal: number;
|
||||||
couponsTotal: number;
|
couponsTotal: number;
|
||||||
principalRepaymentTotal: number;
|
principalRepaymentTotal: number;
|
||||||
|
actualDividendsTotal: number;
|
||||||
|
actualCouponsTotal: number;
|
||||||
|
actualPrincipalRepaymentTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokerEventsData {
|
export interface BrokerEventsData {
|
||||||
|
|||||||
@ -4,13 +4,14 @@
|
|||||||
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
|
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||||
> checkbox (`- [ ]`) syntax for tracking.
|
> checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих событий и ориентировочный прогноз
|
**Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих и прошедших событий с прогнозом
|
||||||
будущих выплат по выбранному периоду.
|
будущих выплат, фактическими прошедшими поступлениями и управляемыми фильтрами.
|
||||||
|
|
||||||
**Architecture:** Backend добавляет отдельный read-only endpoint событий поверх уже существующих
|
**Architecture:** Backend предоставляет единый read-only endpoint событий поверх существующих данных
|
||||||
данных T-Bank и MOEX. Слой агрегации строит best-effort список событий по текущим позициям счёта и
|
T-Bank и MOEX. Слой агрегации объединяет прогнозные события по текущим позициям и фактические
|
||||||
summary по будущим денежным потокам. Frontend расширяет shell брокерского счёта новой вкладкой
|
прошедшие выплаты из операций счёта, затем строит раздельный summary факта и прогноза. Frontend
|
||||||
`События`, отдельной страницей и компактным overview-виджетом ближайших событий.
|
держит фильтры в черновике, применяет их только по кнопке `Показать` и визуально разделяет
|
||||||
|
`Факт`/`Прогноз`.
|
||||||
|
|
||||||
**Tech Stack:** NestJS 10, Prisma, CacheService, MOEX client, T-Bank module, React 18, React Router
|
**Tech Stack:** NestJS 10, Prisma, CacheService, MOEX client, T-Bank module, React 18, React Router
|
||||||
6, TanStack Query 5, TypeScript, Vitest, Testing Library.
|
6, TanStack Query 5, TypeScript, Vitest, Testing Library.
|
||||||
@ -39,13 +40,13 @@ summary по будущим денежным потокам. Frontend расши
|
|||||||
Новый endpoint:
|
Новый endpoint:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/v1/broker/accounts/:accountId/events?from=YYYY-MM-DD&to=YYYY-MM-DD
|
GET /api/v1/broker/accounts/:accountId/events?from=YYYY-MM-DD&to=YYYY-MM-DD&types=dividend,coupon,maturity,offer
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoint возвращает:
|
Endpoint возвращает:
|
||||||
|
|
||||||
- `items` — плоский список событий по текущим позициям счёта;
|
- `items` — плоский список событий по текущим позициям счёта;
|
||||||
- `summary` — агрегаты по денежным событиям за период;
|
- `summary` — агрегаты по фактическим и прогнозным денежным событиям за период;
|
||||||
- `asOf` — момент построения read model.
|
- `asOf` — момент построения read model.
|
||||||
|
|
||||||
### Backend read model
|
### Backend read model
|
||||||
@ -56,7 +57,8 @@ Endpoint возвращает:
|
|||||||
- `BrokerPortfolioService` или внутренний shared-path для получения текущих позиций;
|
- `BrokerPortfolioService` или внутренний shared-path для получения текущих позиций;
|
||||||
- `BrokerInstrumentsService` для сопоставления T-Bank instrument metadata;
|
- `BrokerInstrumentsService` для сопоставления T-Bank instrument metadata;
|
||||||
- `MoexClientService` для дивидендов и bond enrichment;
|
- `MoexClientService` для дивидендов и bond enrichment;
|
||||||
- `CacheService` для кэширования результата по `accountId + from + to`.
|
- `BrokerOperationsService` для получения фактических прошедших выплат;
|
||||||
|
- `CacheService` для кэширования результата по `accountId + from + to + types`.
|
||||||
|
|
||||||
Сервис не записывает события в Prisma и не вводит отдельные таблицы в первой версии.
|
Сервис не записывает события в Prisma и не вводит отдельные таблицы в первой версии.
|
||||||
|
|
||||||
@ -78,14 +80,26 @@ Endpoint возвращает:
|
|||||||
|
|
||||||
### Семантика summary
|
### Семантика summary
|
||||||
|
|
||||||
`summary` агрегирует только денежные события:
|
`summary` агрегирует денежные события раздельно по источнику:
|
||||||
|
|
||||||
- дивиденды;
|
- дивиденды;
|
||||||
- купоны;
|
- купоны;
|
||||||
- погашения.
|
- погашения.
|
||||||
|
|
||||||
Оферты остаются в общем списке событий, но не обязаны входить в сумму денежных потоков первой
|
Поля прогноза считаются только по `source: 'forecast'`, поля факта — только по `source: 'actual'`.
|
||||||
версии.
|
Оферты остаются в общем списке событий, но не входят в денежные итоги.
|
||||||
|
|
||||||
|
### Фактические прошедшие события
|
||||||
|
|
||||||
|
Для прошедшей части диапазона backend читает исполненные операции T-Bank:
|
||||||
|
|
||||||
|
- `OPERATION_TYPE_DIVIDEND` и `OPERATION_TYPE_DIV_EXT` → `dividend`;
|
||||||
|
- `OPERATION_TYPE_COUPON` → `coupon`;
|
||||||
|
- `OPERATION_TYPE_BOND_REPAYMENT` и `OPERATION_TYPE_BOND_REPAYMENT_FULL` → `maturity`.
|
||||||
|
|
||||||
|
Фактическая строка получает `source: 'actual'`, `actualAmount` из `operation.payment`, дату операции
|
||||||
|
как `eventDate`, `estimateMode: null`. Прогнозная строка получает `source: 'forecast'`,
|
||||||
|
`estimatedAmount`, `estimateMode: 'current_position'`.
|
||||||
|
|
||||||
### Частичная деградация
|
### Частичная деградация
|
||||||
|
|
||||||
@ -105,10 +119,11 @@ Endpoint возвращает:
|
|||||||
type BrokerEventsQuery = {
|
type BrokerEventsQuery = {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
types?: string;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Обе даты обязательны в первой версии, чтобы не вводить неочевидные дефолты по периоду.
|
Обе даты обязательны. `types` опционален: если параметр отсутствует, backend использует все типы.
|
||||||
|
|
||||||
### Response contract
|
### Response contract
|
||||||
|
|
||||||
@ -116,6 +131,7 @@ type BrokerEventsQuery = {
|
|||||||
type BrokerPortfolioEvent = {
|
type BrokerPortfolioEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
|
||||||
|
source: 'forecast' | 'actual';
|
||||||
category: 'cashflow' | 'corporate';
|
category: 'cashflow' | 'corporate';
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
paymentDate: string | null;
|
paymentDate: string | null;
|
||||||
@ -126,17 +142,23 @@ type BrokerPortfolioEvent = {
|
|||||||
quantitySnapshot: number | null;
|
quantitySnapshot: number | null;
|
||||||
payoutPerUnit: number | null;
|
payoutPerUnit: number | null;
|
||||||
estimatedAmount: number | null;
|
estimatedAmount: number | null;
|
||||||
|
actualAmount: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
estimateMode: 'current_position';
|
estimateMode: 'current_position' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrokerEventsSummary = {
|
type BrokerEventsSummary = {
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
nearestEventDate: string | null;
|
nearestEventDate: string | null;
|
||||||
totalEstimatedCashflow: number;
|
totalEstimatedCashflow: number;
|
||||||
|
actualCashflow: number;
|
||||||
|
forecastEstimatedCashflow: number;
|
||||||
dividendsTotal: number;
|
dividendsTotal: number;
|
||||||
couponsTotal: number;
|
couponsTotal: number;
|
||||||
principalRepaymentTotal: number;
|
principalRepaymentTotal: number;
|
||||||
|
actualDividendsTotal: number;
|
||||||
|
actualCouponsTotal: number;
|
||||||
|
actualPrincipalRepaymentTotal: number;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -210,12 +232,41 @@ Overview получает компактный блок ближайших со
|
|||||||
|
|
||||||
### Frontend date range UI
|
### Frontend date range UI
|
||||||
|
|
||||||
Даты хранятся в URL-параметрах `from` / `to` для возможности поделиться ссылкой. На странице — два `TextField[type=date]` из дизайн-системы. dayjs для форматирования, валидации и дефолтов.
|
Даты и типы хранятся в URL-параметрах `from` / `to` / `types` для возможности поделиться ссылкой.
|
||||||
|
На странице — два date input из дизайн-системы и multi-select типов событий. dayjs используется для
|
||||||
|
форматирования, валидации и дефолтов.
|
||||||
|
|
||||||
Поток данных:
|
Поток данных:
|
||||||
|
|
||||||
```
|
```
|
||||||
useSearchParams → from/to → useBrokerEvents(query) → TanStack Query (автоrefetch)
|
useSearchParams → applied filters → useBrokerEvents(query) → TanStack Query
|
||||||
|
local draft state → button "Показать" → setSearchParams(applied filters)
|
||||||
```
|
```
|
||||||
|
|
||||||
Дефолт при первом визите без параметров: today – today+7d. Валидация: to >= from. При невалидных датах — показ ошибки под полем, запрос не выполняется.
|
Дефолт при первом визите без параметров: today-7d – today+7d и все типы событий. Валидация: to >=
|
||||||
|
from, выбран хотя бы один тип. При невалидных фильтрах кнопка `Показать` disabled или показывает
|
||||||
|
ошибку, запрос не выполняется. Изменение полей не запускает запрос до применения.
|
||||||
|
|
||||||
|
## Follow-up implementation tasks
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Расширить `BrokerEventsQueryDto` параметром `types` с валидацией comma-separated значений.
|
||||||
|
2. Расширить `BrokerPortfolioEvent` и Swagger DTO полями `source`, `actualAmount`, nullable
|
||||||
|
`estimateMode`.
|
||||||
|
3. Расширить `BrokerEventsSummary` раздельными полями факта и прогноза.
|
||||||
|
4. Добавить в `BrokerEventsService` фильтрацию типов и cache key с `types`.
|
||||||
|
5. Инжектировать `BrokerOperationsService` в `BrokerEventsService` и строить actual events по
|
||||||
|
исполненным операциям для прошедшей части диапазона.
|
||||||
|
6. Исключить дубли forecast/actual для прошедших cashflow-событий по ключу `type + ticker + date`.
|
||||||
|
7. Обновить backend unit/controller tests на query `types`, actual events и summary.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. Расширить handwritten response types и `BrokerEventsQuery` параметром `types`.
|
||||||
|
2. Обновить `useBrokerEvents` query key с учётом `types`.
|
||||||
|
3. Переделать `BrokerEventsPage` на applied filters + draft filters + кнопку `Показать`.
|
||||||
|
4. Добавить multi-select типов событий по дизайн-системе/текущим UI-паттернам проекта.
|
||||||
|
5. Отобразить `Факт`/`Прогноз`, `Поступило`, зелёное выделение actual-сумм и раздельный summary.
|
||||||
|
6. Обновить frontend tests на отсутствие запроса при черновом изменении фильтров, применение по
|
||||||
|
кнопке, multi-select типов и actual event styling.
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
# Календарь событий и прогноз будущих выплат брокерского счёта
|
# Календарь событий и прогноз будущих выплат брокерского счёта
|
||||||
|
|
||||||
Дата: 2026-06-21
|
Дата: 2026-06-21
|
||||||
Статус: согласовано к планированию
|
Статус: реализовано; доработка UX и смешанного календаря согласована к реализации
|
||||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
|
|
||||||
Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть будущие события по
|
Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть будущие события по
|
||||||
бумагам счёта и ориентировочный денежный поток по выбранному диапазону дат.
|
бумагам счёта, фактически прошедшие выплаты и ориентировочный денежный поток по выбранному
|
||||||
|
диапазону дат.
|
||||||
|
|
||||||
## Пользовательский результат
|
## Пользовательский результат
|
||||||
|
|
||||||
@ -18,6 +19,8 @@
|
|||||||
- выбрать период, например с `2026-06-22` по `2026-07-29`;
|
- выбрать период, например с `2026-06-22` по `2026-07-29`;
|
||||||
- получить список дивидендов, купонов, погашений и оферт, попадающих в этот период;
|
- получить список дивидендов, купонов, погашений и оферт, попадающих в этот период;
|
||||||
- увидеть ориентировочную сумму будущих выплат по выбранному диапазону;
|
- увидеть ориентировочную сумму будущих выплат по выбранному диапазону;
|
||||||
|
- увидеть фактически поступившие прошедшие выплаты из операций брокерского счёта;
|
||||||
|
- выбрать несколько типов событий, которые нужно показать;
|
||||||
- понимать, какие значения являются оценкой по текущим позициям, а не подтверждённым правом на
|
- понимать, какие значения являются оценкой по текущим позициям, а не подтверждённым правом на
|
||||||
выплату.
|
выплату.
|
||||||
|
|
||||||
@ -28,7 +31,9 @@
|
|||||||
- новый блок ближайших событий на overview счёта;
|
- новый блок ближайших событий на overview счёта;
|
||||||
- новую вкладку `События` в навигации брокерского счёта;
|
- новую вкладку `События` в навигации брокерского счёта;
|
||||||
- фильтр диапазона дат;
|
- фильтр диапазона дат;
|
||||||
|
- фильтр нескольких типов событий;
|
||||||
- список событий по текущим позициям счёта;
|
- список событий по текущим позициям счёта;
|
||||||
|
- список прошедших фактических выплат по операциям счёта;
|
||||||
- агрегированный summary по будущим выплатам за выбранный период.
|
- агрегированный summary по будущим выплатам за выбранный период.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
@ -76,6 +81,19 @@
|
|||||||
- Границы диапазона включительные.
|
- Границы диапазона включительные.
|
||||||
- Если `eventDate` не попадает в диапазон, событие не показывается.
|
- Если `eventDate` не попадает в диапазон, событие не показывается.
|
||||||
- В первой версии отдельный режим фильтрации по `paymentDate` отсутствует.
|
- В первой версии отдельный режим фильтрации по `paymentDate` отсутствует.
|
||||||
|
- При первом открытии вкладки без параметров период по умолчанию равен `сегодня - 7 дней` / `сегодня
|
||||||
|
+ 7 дней`.
|
||||||
|
- Изменение дат в интерфейсе не запускает запрос автоматически: пользователь редактирует черновик
|
||||||
|
фильтров и применяет его кнопкой `Показать`.
|
||||||
|
|
||||||
|
### 4.1. Фильтр типов событий
|
||||||
|
|
||||||
|
- Пользователь может выбрать несколько типов событий через multi-select: `dividend`, `coupon`,
|
||||||
|
`maturity`, `offer`.
|
||||||
|
- При первом открытии включены все типы событий.
|
||||||
|
- После применения фильтра выбранные типы сохраняются в URL в параметре `types`.
|
||||||
|
- URL отражает только применённые фильтры, а не черновые значения в полях.
|
||||||
|
- Если пользователь снимает все типы, запрос не выполняется, а UI показывает валидационное сообщение.
|
||||||
|
|
||||||
### 5. Источники данных
|
### 5. Источники данных
|
||||||
|
|
||||||
@ -119,6 +137,22 @@
|
|||||||
- В первой версии оферта считается информационным событием.
|
- В первой версии оферта считается информационным событием.
|
||||||
- Для оферты не требуется обязательная денежная оценка.
|
- Для оферты не требуется обязательная денежная оценка.
|
||||||
|
|
||||||
|
### 6.1. Фактические прошедшие события
|
||||||
|
|
||||||
|
- Для прошедшей части выбранного диапазона календарь добавляет фактические события из операций
|
||||||
|
T-Bank.
|
||||||
|
- Фактические события строятся только по исполненным операциям счёта.
|
||||||
|
- В фактические события входят дивиденды, купоны и погашения облигаций.
|
||||||
|
- Фактические события имеют источник `actual`, не являются оценкой и используют фактическую сумму
|
||||||
|
операции.
|
||||||
|
- Фактические поступления визуально выделяются зелёным как уже поступившие деньги.
|
||||||
|
- Будущие события имеют источник `forecast`, строятся по текущим позициям и сохраняют признак оценки
|
||||||
|
`current_position`.
|
||||||
|
- Оферты остаются прогнозными событиями, если доступны по данным облигаций; фактическая оферта из
|
||||||
|
операций в этой доработке не строится.
|
||||||
|
- Если одно и то же событие доступно как факт и как прогноз за прошедшую дату, UI должен отдавать
|
||||||
|
приоритет факту, чтобы не показывать пользователю дубль одного поступления.
|
||||||
|
|
||||||
### 7. Overview счёта
|
### 7. Overview счёта
|
||||||
|
|
||||||
- Overview показывает ближайшие 3-5 событий выбранного счёта.
|
- Overview показывает ближайшие 3-5 событий выбранного счёта.
|
||||||
@ -128,17 +162,23 @@
|
|||||||
|
|
||||||
### 8. Вкладка `События`
|
### 8. Вкладка `События`
|
||||||
|
|
||||||
- Вкладка содержит фильтр периода, summary и список событий.
|
- Вкладка содержит фильтр периода, multi-select типов событий, summary и список событий.
|
||||||
|
- Фильтры используют компоненты и визуальные паттерны дизайн-системы.
|
||||||
|
- Изменение фильтров не запускает запрос до нажатия кнопки `Показать`.
|
||||||
|
- Кнопка `Показать` применяет фильтры, обновляет URL и запускает загрузку данных.
|
||||||
- Список событий показывает:
|
- Список событий показывает:
|
||||||
- дату события;
|
- дату события;
|
||||||
- тип события;
|
- тип события;
|
||||||
|
- источник события (`Факт` или `Прогноз`);
|
||||||
- инструмент;
|
- инструмент;
|
||||||
- тип инструмента;
|
- тип инструмента;
|
||||||
- количество бумаг, использованное для расчёта;
|
- количество бумаг, использованное для расчёта;
|
||||||
- выплату на единицу при наличии;
|
- выплату на единицу при наличии;
|
||||||
- итоговую ориентировочную сумму при наличии;
|
- итоговую ориентировочную сумму при наличии;
|
||||||
|
- фактическую сумму поступления при наличии;
|
||||||
- валюту при наличии.
|
- валюту при наличии.
|
||||||
- Для денежных оценок UI показывает признак `estimate`.
|
- Для денежных оценок UI показывает признак `estimate`.
|
||||||
|
- Для фактических поступлений UI показывает признак `Поступило` и зелёное выделение суммы или статуса.
|
||||||
|
|
||||||
### 9. Summary по периоду
|
### 9. Summary по периоду
|
||||||
|
|
||||||
@ -146,12 +186,13 @@ Summary по выбранному периоду показывает:
|
|||||||
|
|
||||||
- количество событий;
|
- количество событий;
|
||||||
- ближайшую дату события;
|
- ближайшую дату события;
|
||||||
- общий ориентировочный денежный поток;
|
- общий ориентировочный денежный поток по прогнозам;
|
||||||
|
- общий фактический денежный поток по прошедшим поступлениям;
|
||||||
- сумму дивидендов;
|
- сумму дивидендов;
|
||||||
- сумму купонов;
|
- сумму купонов;
|
||||||
- сумму погашений.
|
- сумму погашений.
|
||||||
|
|
||||||
Оферты не обязаны входить в денежный итог первой версии.
|
Оферты не обязаны входить в денежные итоги.
|
||||||
|
|
||||||
### 10. Ошибки, пустые состояния и частичная деградация
|
### 10. Ошибки, пустые состояния и частичная деградация
|
||||||
|
|
||||||
@ -174,9 +215,16 @@ Summary по выбранному периоду показывает:
|
|||||||
- На overview брокерского счёта отображается блок ближайших событий.
|
- На overview брокерского счёта отображается блок ближайших событий.
|
||||||
- В навигации брокерского счёта есть вкладка `События`.
|
- В навигации брокерского счёта есть вкладка `События`.
|
||||||
- Пользователь может задать диапазон дат.
|
- Пользователь может задать диапазон дат.
|
||||||
|
- По умолчанию вкладка открывает период `сегодня - 7 дней` / `сегодня + 7 дней`.
|
||||||
|
- Изменение дат или типов событий не запускает запрос до нажатия `Показать`.
|
||||||
|
- Пользователь может выбрать несколько типов событий через multi-select.
|
||||||
|
- Выбранные применённые фильтры восстанавливаются из URL.
|
||||||
- Вкладка показывает только события, дата которых попадает в выбранный диапазон.
|
- Вкладка показывает только события, дата которых попадает в выбранный диапазон.
|
||||||
- Пользователь видит дивиденды, купоны, погашения и оферты, если они доступны по текущим позициям.
|
- Пользователь видит дивиденды, купоны, погашения и оферты, если они доступны по текущим позициям.
|
||||||
|
- Пользователь видит фактические прошедшие дивиденды, купоны и погашения из операций счёта.
|
||||||
|
- Фактические прошедшие поступления помечены как `Поступило` и визуально выделены зелёным.
|
||||||
- Summary показывает агрегированный прогноз будущих выплат по диапазону.
|
- Summary показывает агрегированный прогноз будущих выплат по диапазону.
|
||||||
|
- Summary отдельно показывает фактические поступления и прогноз выплат.
|
||||||
- Все денежные суммы явно обозначены как оценочные.
|
- Все денежные суммы явно обозначены как оценочные.
|
||||||
- Пустой диапазон отображается как отдельное пустое состояние.
|
- Пустой диапазон отображается как отдельное пустое состояние.
|
||||||
- Ошибка по одному инструменту не ломает весь ответ.
|
- Ошибка по одному инструменту не ломает весь ответ.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Календарь событий и прогноз будущих выплат брокерского счёта — задачи
|
# Календарь событий и прогноз будущих выплат брокерского счёта — задачи
|
||||||
|
|
||||||
Статус: реализовано (1-я версия, read-only, T-Bank)
|
Статус: реализовано (1-я версия, read-only, T-Bank); доработка UX и смешанного календаря в работе
|
||||||
|
|
||||||
Связанные документы:
|
Связанные документы:
|
||||||
|
|
||||||
@ -86,3 +86,23 @@
|
|||||||
- [ ] Прогнать docs build, если менялась опубликованная документация.
|
- [ ] Прогнать docs build, если менялась опубликованная документация.
|
||||||
(Deferred: docs не менялись.)
|
(Deferred: docs не менялись.)
|
||||||
- [x] Отметить roadmap и связанные SDD-статусы.
|
- [x] Отметить roadmap и связанные SDD-статусы.
|
||||||
|
|
||||||
|
## 10. Follow-up: UX фильтров и прошедшие события
|
||||||
|
|
||||||
|
- [x] Согласовать UX-дизайн доработки с пользователем.
|
||||||
|
- [x] Создать feature branch `codex/broker-events-calendar-ux`.
|
||||||
|
- [x] Обновить `spec.md` под смешанный календарь факта и прогноза.
|
||||||
|
- [x] Обновить `plan.md` под backend/frontend реализацию доработки.
|
||||||
|
- [x] Прогнать baseline tests перед реализацией.
|
||||||
|
- [x] Расширить backend query параметром `types`.
|
||||||
|
- [x] Расширить backend response полями `source`, `actualAmount`, nullable `estimateMode` и раздельным
|
||||||
|
summary.
|
||||||
|
- [x] Добавить фактические прошедшие события из операций T-Bank.
|
||||||
|
- [x] Добавить фильтрацию нескольких типов событий.
|
||||||
|
- [x] Покрыть backend tests для `types`, actual events, summary и controller forwarding.
|
||||||
|
- [x] Обновить frontend response/query types.
|
||||||
|
- [x] Перевести страницу `События` на applied filters + draft filters + кнопку `Показать`.
|
||||||
|
- [x] Добавить multi-select типов событий.
|
||||||
|
- [x] Отобразить `Факт`/`Прогноз`, `Поступило` и зелёное выделение фактических выплат.
|
||||||
|
- [x] Покрыть frontend tests для нового UX и actual events.
|
||||||
|
- [x] Прогнать финальные проверки затронутых backend/frontend пакетов.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user