feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -7,285 +7,250 @@ import {
|
||||
isZero,
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api';
|
||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||
import { decimal } from '../../../integrations/tbank/decimal';
|
||||
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankDividend,
|
||||
TbankInstrument,
|
||||
TbankPortfolio,
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
type TbankDividend,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
PortfolioService,
|
||||
ForecastService,
|
||||
type PortfolioPositionDto,
|
||||
} from '../portfolio/portfolio.api';
|
||||
import { CatalogEnrichmentService } from '../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
UpcomingEventDto,
|
||||
UpcomingEventsDto,
|
||||
UpcomingEventsQueryDto,
|
||||
} from './upcoming-events.dto';
|
||||
|
||||
const DEFAULT_EVENT_WINDOW_DAYS = 30;
|
||||
|
||||
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
||||
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
|
||||
|
||||
const positionsBatchSize = 5;
|
||||
const dividendCacheTtlMs = 900_000;
|
||||
const instrumentCacheTtlMs = 86_400_000;
|
||||
const calendarReadBatchSize = 100;
|
||||
type Position = PortfolioPositionDto;
|
||||
|
||||
@Injectable()
|
||||
export class UpcomingEventsService {
|
||||
private readonly dividendCache = new Map<
|
||||
string,
|
||||
CachedEntry<TbankDividend[]>
|
||||
>();
|
||||
private readonly instrumentCache = new Map<
|
||||
string,
|
||||
CachedEntry<TbankInstrument | null>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly portfolios: PortfolioService,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
private readonly forecasts: ForecastService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
query: UpcomingEventsQueryDto = {},
|
||||
): Promise<UpcomingEventsDto> {
|
||||
) {
|
||||
const window = dateWindow(query);
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
return this.access.withAccount(userId, accountId, ({ token }) =>
|
||||
this.forAccount(token, accountId, window),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(
|
||||
userId: string,
|
||||
query: UpcomingEventsQueryDto = {},
|
||||
): Promise<UpcomingEventsDto> {
|
||||
const window = dateWindow(query);
|
||||
return this.access.withSupportedAccounts(
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const portfolio = await this.portfolios.read(
|
||||
userId,
|
||||
async ({ token, accounts }) => {
|
||||
const events: (UpcomingEventDto & {
|
||||
account: { id: string; name: string };
|
||||
})[] = [];
|
||||
for (const account of accounts) {
|
||||
const result = await this.forAccount(token, account.id, window);
|
||||
events.push(
|
||||
...result.events.map((event) => ({
|
||||
...event,
|
||||
account: { id: account.id, name: account.name },
|
||||
})),
|
||||
);
|
||||
}
|
||||
events.sort(
|
||||
(left, right) =>
|
||||
left.date.localeCompare(right.date) ||
|
||||
left.account.id.localeCompare(right.account.id),
|
||||
accountId,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||
[];
|
||||
const events: UpcomingEventDto[] = [];
|
||||
let ready = portfolio.data !== null;
|
||||
const positions = portfolio.data?.positions.filter(supported) ?? [];
|
||||
const metadata = await this.metadata.savedMany(positions);
|
||||
for (const type of ['share', 'bond'] as const) {
|
||||
const identities = positions
|
||||
.filter(
|
||||
(position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'),
|
||||
)
|
||||
.map((position) => ({
|
||||
uid:
|
||||
position.instrumentUid ||
|
||||
metadata.get(metadataKey(position))?.data?.uid,
|
||||
ticker:
|
||||
position.ticker ||
|
||||
metadata.get(metadataKey(position))?.data?.ticker ||
|
||||
'',
|
||||
}))
|
||||
.filter((entry): entry is { uid: string; ticker: string } =>
|
||||
Boolean(entry.uid),
|
||||
);
|
||||
return {
|
||||
accountId: 'all',
|
||||
const uids = [...new Set(identities.map((entry) => entry.uid))];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < uids.length;
|
||||
offset += calendarReadBatchSize
|
||||
)
|
||||
sources.push(
|
||||
...(await this.sources.readMany(
|
||||
userId,
|
||||
type,
|
||||
uids.slice(offset, offset + calendarReadBatchSize),
|
||||
query.sync === 'observe',
|
||||
['detail', type === 'share' ? 'dividends' : 'bond-events'],
|
||||
{
|
||||
...window,
|
||||
tickers: Object.fromEntries(
|
||||
identities.map((entry) => [entry.uid, entry.ticker]),
|
||||
),
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
for (const position of positions) {
|
||||
const generic = metadata.get(metadataKey(position))?.data;
|
||||
const uid = position.instrumentUid || generic?.uid;
|
||||
if (!uid) {
|
||||
ready = false;
|
||||
sources.push({
|
||||
input: { uid: '', instrumentType: 'share', kind: 'detail' },
|
||||
version: null,
|
||||
payload: undefined,
|
||||
instrument: null,
|
||||
price: null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `events/identity/${position.figi ?? ''}`,
|
||||
dataRevision: '0',
|
||||
availability: 'missing',
|
||||
fetchedAt: null,
|
||||
freshForMs: 0,
|
||||
work: null,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const type = position.type === 'STOCK' ? 'share' : 'bond';
|
||||
const calendar = sources.find(
|
||||
(source) =>
|
||||
source.input.uid === uid &&
|
||||
source.input.kind ===
|
||||
(type === 'share' ? 'dividends' : 'bond-events'),
|
||||
);
|
||||
const instrument =
|
||||
sources.find(
|
||||
(source) =>
|
||||
source.input.uid === uid && source.input.kind === 'detail',
|
||||
)?.instrument || generic;
|
||||
const ticker = position.ticker || instrument?.ticker;
|
||||
if (!calendar || calendar.meta.availability !== 'complete' || !ticker) {
|
||||
ready = false;
|
||||
continue;
|
||||
}
|
||||
const identity = {
|
||||
figi: position.figi || null,
|
||||
instrumentUid: uid,
|
||||
ticker,
|
||||
displayName: instrument?.name?.trim() || position.displayName || ticker,
|
||||
isin: instrument?.isin || position.isin || null,
|
||||
logoName: instrument?.brand?.logoName || position.logoName || null,
|
||||
};
|
||||
const quantity = decimalFromString(position.quantity);
|
||||
const projected =
|
||||
type === 'share'
|
||||
? (calendar.payload?.dividends ?? [])
|
||||
.filter(
|
||||
(event) =>
|
||||
event.dividendType?.toLowerCase() !== 'cancelled' &&
|
||||
Boolean(event.recordDate),
|
||||
)
|
||||
.map((event) => dividendEvent(identity, quantity, event))
|
||||
: (calendar.payload?.bondEvents ?? []).map((event) =>
|
||||
bondEvent(identity, quantity, event),
|
||||
);
|
||||
events.push(
|
||||
...projected
|
||||
.filter(
|
||||
(event) => event.date >= window.from && event.date <= window.to,
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
...(accountId === 'all' && position.account
|
||||
? { account: position.account }
|
||||
: {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
events.sort(
|
||||
(a, b) =>
|
||||
a.date.localeCompare(b.date) ||
|
||||
(a.account?.id ?? '').localeCompare(b.account?.id ?? '') ||
|
||||
a.type.localeCompare(b.type) ||
|
||||
a.ticker.localeCompare(b.ticker),
|
||||
);
|
||||
const candidate: UpcomingEventsDto | undefined = ready
|
||||
? {
|
||||
accountId,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
events,
|
||||
incomeTotalsByCurrency: incomeTotals(events),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async forAccount(
|
||||
token: string,
|
||||
accountId: string,
|
||||
window: { from: string; to: string },
|
||||
): Promise<UpcomingEventsDto> {
|
||||
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
||||
const supportedPositions = (portfolio.positions ?? []).filter(supported);
|
||||
const events: UpcomingEventDto[][] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < supportedPositions.length;
|
||||
offset += positionsBatchSize
|
||||
) {
|
||||
const batch = supportedPositions.slice(
|
||||
offset,
|
||||
offset + positionsBatchSize,
|
||||
);
|
||||
events.push(
|
||||
...(await Promise.all(
|
||||
batch.map((position) => this.positionEvents(token, position, window)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
const projected = events
|
||||
.flat()
|
||||
.filter((event) => event.date >= window.from && event.date <= window.to);
|
||||
this.logger.info('upcoming-events.calculated', {
|
||||
count: projected.length,
|
||||
}
|
||||
: undefined;
|
||||
return this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
membershipRevision: connection.membershipRevision,
|
||||
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
|
||||
portfolio: portfolio.meta,
|
||||
sources,
|
||||
candidate,
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
events: projected.sort(
|
||||
(left, right) =>
|
||||
left.date.localeCompare(right.date) ||
|
||||
left.type.localeCompare(right.type) ||
|
||||
left.ticker.localeCompare(right.ticker),
|
||||
),
|
||||
incomeTotalsByCurrency: incomeTotals(projected),
|
||||
};
|
||||
}
|
||||
|
||||
private async positionEvents(
|
||||
token: string,
|
||||
position: Position,
|
||||
window: { from: string; to: string },
|
||||
): Promise<UpcomingEventDto[]> {
|
||||
const ticker = position.ticker?.trim();
|
||||
const instrument = ticker
|
||||
? await this.instrument(token, position).catch(() => {
|
||||
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
|
||||
return null;
|
||||
})
|
||||
: await this.instrument(token, position);
|
||||
const resolvedTicker = ticker || instrument?.ticker?.trim();
|
||||
if (!resolvedTicker) return [];
|
||||
const metadata = {
|
||||
figi: position.figi?.trim() || null,
|
||||
instrumentUid: position.instrumentUid?.trim() || null,
|
||||
ticker: resolvedTicker,
|
||||
displayName: instrument?.name?.trim() || resolvedTicker,
|
||||
isin: instrument?.isin?.trim() || null,
|
||||
logoName: instrument?.brand?.logoName?.trim() || null,
|
||||
};
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (position.instrumentType?.toLowerCase() === 'share') {
|
||||
const dividends = await this.dividends(token, position, window);
|
||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||
count: dividends.length,
|
||||
});
|
||||
return dividends.map((event) => this.dividend(metadata, quantity, event));
|
||||
}
|
||||
const events = await this.moex.bondEvents(resolvedTicker);
|
||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||
count: events.length,
|
||||
});
|
||||
return events.map((event) => this.bond(metadata, quantity, event));
|
||||
getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
|
||||
return this.get(userId, 'all', query);
|
||||
}
|
||||
}
|
||||
|
||||
private async instrument(token: string, position: Position) {
|
||||
const instrumentUid = position.instrumentUid?.trim();
|
||||
if (instrumentUid)
|
||||
return this.cached(
|
||||
this.instrumentCache,
|
||||
instrumentUid,
|
||||
instrumentCacheTtlMs,
|
||||
() => this.tbank.getInstrumentByUid(token, instrumentUid),
|
||||
);
|
||||
const figi = position.figi?.trim();
|
||||
if (figi)
|
||||
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
|
||||
this.tbank.getInstrumentByFigi(token, figi),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
export function dividendEvent(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: TbankDividend,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: 'dividend',
|
||||
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
dateKind: 'registry',
|
||||
projectedAmount: event.dividendNet
|
||||
? projectedMoney(
|
||||
quantity,
|
||||
formatDecimal(decimal(event.dividendNet)),
|
||||
event.dividendNet.currency,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async dividends(
|
||||
token: string,
|
||||
position: Position,
|
||||
window: { from: string; to: string },
|
||||
): Promise<TbankDividend[]> {
|
||||
const instrumentId = position.instrumentUid ?? position.figi;
|
||||
if (!instrumentId) return [];
|
||||
const dividends = await this.cached(
|
||||
this.dividendCache,
|
||||
JSON.stringify([instrumentId, window.from, window.to]),
|
||||
dividendCacheTtlMs,
|
||||
() =>
|
||||
this.tbank.getDividends(token, instrumentId, window.from, window.to),
|
||||
);
|
||||
return dividends.filter(
|
||||
(event) =>
|
||||
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
|
||||
);
|
||||
}
|
||||
|
||||
private async cached<T>(
|
||||
cache: Map<string, CachedEntry<T>>,
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
load: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expiresAt > now) return hit.promise;
|
||||
const promise = load().catch((error: unknown) => {
|
||||
cache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
cache.set(key, { expiresAt: now + ttlMs, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
private dividend(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: TbankDividend,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: 'dividend',
|
||||
date: new Date(
|
||||
Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
dateKind: 'registry',
|
||||
projectedAmount: event.dividendNet
|
||||
? projectedMoney(
|
||||
quantity,
|
||||
formatDecimal(decimal(event.dividendNet)),
|
||||
event.dividendNet.currency,
|
||||
)
|
||||
export function bondEvent(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: MoexBondEvent,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: event.type,
|
||||
date: event.date,
|
||||
dateKind: 'event',
|
||||
projectedAmount:
|
||||
'value' in event
|
||||
? projectedMoney(quantity, event.value, event.currency)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private bond(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: MoexBondEvent,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: event.type,
|
||||
date: event.date,
|
||||
dateKind: 'event',
|
||||
projectedAmount:
|
||||
'value' in event
|
||||
? projectedMoney(quantity, event.value, event.currency)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function supported(position: Position): boolean {
|
||||
return (
|
||||
!isZero(decimal(position.quantity ?? {})) &&
|
||||
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
|
||||
!isZero(decimalFromString(position.quantity)) &&
|
||||
['STOCK', 'BOND'].includes(position.type)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user