moex-vibe/apps/backend/src/modules/tbank/services/broker-events.service.ts

427 lines
14 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper';
import type {
BrokerOperation,
BrokerPortfolioEvent,
BrokerEventsData,
BrokerEventsSummary,
} from '../types/broker.types';
import type { TBankInstrument } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.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()
export class BrokerEventsService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService,
private readonly moexClient: MoexClientService,
private readonly operationsService: BrokerOperationsService,
private readonly cacheService: CacheService,
) {}
async getEvents(
accountId: string,
query: BrokerEventsQuery,
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
const account = await this.accountsService.findById(accountId);
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(
TBANK_CACHE_KEYS.events,
[accountId, query.from, query.to, eventTypeKey],
() => this.buildEvents(accountId, query.from, query.to, eventTypes),
'tbankPortfolioTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async buildEvents(
accountId: string,
from: string,
to: string,
eventTypes: Set<BrokerEventType>,
): Promise<BrokerEventsData> {
const { positions, instruments } =
await this.portfolioService.getPositionsWithInstruments(accountId);
const items: BrokerPortfolioEvent[] = [];
const sharePositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'share');
const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond');
const shareResults = await Promise.allSettled(
eventTypes.has('dividend')
? sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to))
: [],
);
for (const r of shareResults) {
if (r.status === 'fulfilled') items.push(...r.value);
}
if (bondPositions.length > 0) {
const bondEvents = await this.buildBondEvents(
bondPositions,
instruments,
from,
to,
eventTypes,
);
items.push(...bondEvents);
}
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 allItems = [...actualEvents, ...deduplicatedForecasts];
allItems.sort((a, b) => a.eventDate.localeCompare(b.eventDate));
const summary = this.buildSummary(allItems);
return { items: allItems, summary, asOf: new Date().toISOString() };
}
private async buildShareEvents(
pos: {
ticker?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: number };
},
instruments: Map<string, Partial<TBankInstrument>>,
from: string,
to: string,
): Promise<BrokerPortfolioEvent[]> {
const ticker = pos.ticker || null;
if (!ticker) return [];
const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined;
const quantity = mapQuotationToNumber(pos.quantity);
const instrumentUid = pos.instrumentUid || instrument?.uid || null;
const name = instrument?.name || null;
let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
try {
dividends = await this.moexClient.getDividends(ticker);
} catch {
return [];
}
const events: BrokerPortfolioEvent[] = [];
for (const d of dividends) {
if (d.registryCloseDate < from || d.registryCloseDate > to) continue;
const payoutPerUnit = d.value ?? null;
const estimatedAmount =
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
events.push({
id: `div-${ticker}-${d.registryCloseDate}`,
type: 'dividend',
source: 'forecast',
category: 'cashflow',
eventDate: d.registryCloseDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'share',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
actualAmount: null,
currency: d.currencyId,
estimateMode: 'current_position',
});
}
return events;
}
private async buildBondEvents(
bondPositions: {
ticker?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: number };
}[],
instruments: Map<string, Partial<TBankInstrument>>,
from: string,
to: string,
eventTypes: Set<BrokerEventType>,
): Promise<BrokerPortfolioEvent[]> {
const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t));
if (secids.length === 0) return [];
let bondData: {
secid: string;
couponValue: number | null;
nextCouponDate: string | null;
matDate: string | null;
offerDate: string | null;
faceValue: number;
}[];
try {
bondData = await this.moexClient.getBondPositionDataBatch(secids);
} catch {
return [];
}
const events: BrokerPortfolioEvent[] = [];
for (const pos of bondPositions) {
const ticker = pos.ticker;
if (!ticker) continue;
const bond = bondData.find((b) => b.secid === ticker);
if (!bond) continue;
const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined;
const quantity = mapQuotationToNumber(pos.quantity);
const instrumentUid = pos.instrumentUid || instrument?.uid || null;
const name = instrument?.name || null;
const currency = instrument?.currency || 'RUB';
if (
eventTypes.has('coupon') &&
bond.nextCouponDate &&
bond.nextCouponDate >= from &&
bond.nextCouponDate <= to
) {
const payoutPerUnit = bond.couponValue;
const estimatedAmount =
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
events.push({
id: `coupon-${ticker}-${bond.nextCouponDate}`,
type: 'coupon',
source: 'forecast',
category: 'cashflow',
eventDate: bond.nextCouponDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
actualAmount: null,
currency,
estimateMode: 'current_position',
});
}
if (
eventTypes.has('maturity') &&
bond.matDate &&
bond.matDate >= from &&
bond.matDate <= to
) {
const payoutPerUnit = bond.faceValue;
const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null;
events.push({
id: `maturity-${ticker}-${bond.matDate}`,
type: 'maturity',
source: 'forecast',
category: 'cashflow',
eventDate: bond.matDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
actualAmount: null,
currency,
estimateMode: 'current_position',
});
}
if (
eventTypes.has('offer') &&
bond.offerDate &&
bond.offerDate >= from &&
bond.offerDate <= to
) {
events.push({
id: `offer-${ticker}-${bond.offerDate}`,
type: 'offer',
source: 'forecast',
category: 'corporate',
eventDate: bond.offerDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit: null,
estimatedAmount: null,
actualAmount: null,
currency,
estimateMode: 'current_position',
});
}
}
return events;
}
private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary {
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 {
eventCount: items.length,
nearestEventDate: items.length > 0 ? items[0].eventDate : null,
totalEstimatedCashflow: forecastEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
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')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
couponsTotal: forecastEvents
.filter((e) => e.type === 'coupon')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
principalRepaymentTotal: forecastEvents
.filter((e) => e.type === 'maturity')
.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}`;
}
}