- MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker) - MoexSecuritiesClient: search and security descriptions - MoexMarketDataClient: share/bond market data and batch queries - MoexCandlesClient: candle data - MoexHistoryClient: share/bond history - MoexDividendsClient: dividend data - Removed @Global() from MoexClientModule - Updated all 7 consumers with explicit DI - All 141 tests passing
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { CacheService } from '../../cache/cache.service';
|
|
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
|
|
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
|
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
|
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 moexMarketData: MoexMarketDataClient,
|
|
private readonly moexDividends: MoexDividendsClient,
|
|
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 EntityNotFoundException('BrokerAccount', accountId);
|
|
|
|
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.moexDividends.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.moexMarketData.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}`;
|
|
}
|
|
}
|