style: align multiline ternary operators
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m11s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 34s

This commit is contained in:
2026-09-28 12:33:56 +03:00
parent 9cc9fbdcff
commit 0303e60b64
54 changed files with 986 additions and 982 deletions
+5 -1
View File
@@ -48,7 +48,11 @@ export default defineConfig(
'perfectionist/sort-classes': 'off', 'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'], '@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'after'], '@stylistic/operator-linebreak': [
'error',
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
+10 -10
View File
@@ -15,18 +15,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
const status = const status =
exception instanceof HttpException ? exception instanceof HttpException
exception.getStatus() : ? exception.getStatus()
HttpStatus.INTERNAL_SERVER_ERROR; : HttpStatus.INTERNAL_SERVER_ERROR;
const payload = const payload =
exception instanceof HttpException ? exception.getResponse() : undefined; exception instanceof HttpException ? exception.getResponse() : undefined;
const isValidation = Array.isArray( const isValidation = Array.isArray(
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation ? const code = isValidation
'VALIDATION_ERROR' : ? 'VALIDATION_ERROR'
this.codeFor(status, payload); : this.codeFor(status, payload);
response.locals ??= {}; response.locals ??= {};
response.locals.publicErrorCode = code; response.locals.publicErrorCode = code;
response response
@@ -35,13 +35,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
.json({ .json({
status, status,
code, code,
...(isValidation ? ...(isValidation
{ ? {
errors: this.validationErrors( errors: this.validationErrors(
(payload as { message: ValidationError[] }).message, (payload as { message: ValidationError[] }).message,
), ),
} : }
{}), : {}),
}); });
} }
@@ -33,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware {
const routePath = (routeRequest as { route?: { path?: unknown } }).route const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path; ?.path;
const route = const route =
typeof routePath === 'string' ? typeof routePath === 'string'
`${request.baseUrl}${routePath}` : ? `${request.baseUrl}${routePath}`
'<unmatched>'; : '<unmatched>';
const context = { const context = {
method: request.method, method: request.method,
route, route,
status: response.statusCode, status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode ? ...(response.locals.publicErrorCode
{ code: response.locals.publicErrorCode as string } : ? { code: response.locals.publicErrorCode as string }
{}), : {}),
}; };
const level = const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
'error' : ? 'error'
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
'warn' : ? 'warn'
'info'; : 'info';
this.logger.event(level, 'http.request.completed', context); this.logger.event(level, 'http.request.completed', context);
}); });
+3 -3
View File
@@ -105,9 +105,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
JWT_SECRET: jwtSecret, JWT_SECRET: jwtSecret,
PORT: parsedPort, PORT: parsedPort,
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
{ TBANK_CA_CERT_PATH: tbankCaCertPath } : ? { TBANK_CA_CERT_PATH: tbankCaCertPath }
{}), : {}),
TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_GRPC_ENDPOINT: tbankEndpoint,
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
@@ -43,12 +43,12 @@ export class InstrumentMetadataRepository {
async readMany(identities: MetadataIdentity[]) { async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [ const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim() ? ...(identity.instrumentUid?.trim()
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
[]), : []),
...(identity.figi?.trim() ? ...(identity.figi?.trim()
[{ identifierType: 'figi', identifier: identity.figi.trim() }] : ? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
[]), : []),
]); ]);
if (!identifiers.length) { if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>(); return new Map<string, ReturnType<typeof selectMetadata>>();
@@ -233,9 +233,9 @@ export class InstrumentMetadataRepository {
update: { update: {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) ? increment: isDeepStrictEqual(previous?.metadata, payload)
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -256,9 +256,9 @@ export class InstrumentMetadataRepository {
matchedUids, matchedUids,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
+12 -12
View File
@@ -205,10 +205,10 @@ export class TbankClient {
loadPackageDefinition(definition) as unknown as TbankNamespace loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath ? const credentials = caPath
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
ChannelCredentials.createSsl(readFileSync(caPath)) : ? ChannelCredentials.createSsl(readFileSync(caPath))
ChannelCredentials.createSsl(); : ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
@@ -497,12 +497,12 @@ export class TbankClient {
return response; return response;
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull ? const failureContext = queueFull
{ code: 'TBANK_UNAVAILABLE' } : ? { code: 'TBANK_UNAVAILABLE' }
{ grpcCode }; : { grpcCode };
const retryable = queueFull ? const retryable = queueFull
metadataCall : ? metadataCall
[ : [
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE, status.UNAVAILABLE,
@@ -517,9 +517,9 @@ export class TbankClient {
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout( setTimeout(
resolve, resolve,
metadataCall ? metadataCall
attempts * METADATA_RETRY_DELAY_MS : ? attempts * METADATA_RETRY_DELAY_MS
RPC_RETRY_DELAY_MS, : RPC_RETRY_DELAY_MS,
), ),
); );
continue; continue;
@@ -103,17 +103,17 @@ export class BankCardsService {
id, id,
name: dto.name ?? previous.name, name: dto.name ?? previous.name,
bankName: bankName:
dto.bankName === undefined ? dto.bankName === undefined
(previous.bankName ?? undefined) : ? (previous.bankName ?? undefined)
(dto.bankName ?? undefined), : (dto.bankName ?? undefined),
lastFour: lastFour:
dto.lastFour === undefined ? dto.lastFour === undefined
(previous.lastFour ?? undefined) : ? (previous.lastFour ?? undefined)
(dto.lastFour ?? undefined), : (dto.lastFour ?? undefined),
paymentSystem: paymentSystem:
dto.paymentSystem === undefined ? dto.paymentSystem === undefined
previous.paymentSystem : ? previous.paymentSystem
dto.paymentSystem, : dto.paymentSystem,
openingBalance: openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate, openingDate: dto.openingDate ?? previous.openingDate,
@@ -143,12 +143,12 @@ export class BankCardsService {
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
...(dto.archived === undefined ? ...(dto.archived === undefined
{} : ? {}
{ : {
archivedAt: dto.archived ? archivedAt: dto.archived
(previous.archivedAt ?? new Date()) : ? (previous.archivedAt ?? new Date())
null, : null,
}), }),
}, },
}); });
@@ -43,29 +43,29 @@ export class BankOperationsService {
row: BankCardOperation, row: BankCardOperation,
): Promise<BankOperationDto> { ): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId); const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId ? const target = row.targetCardId
await this.repository.card(tx, row.userId, row.targetCardId) : ? await this.repository.card(tx, row.userId, row.targetCardId)
null; : null;
const source = row.originalExpenseId ? const source = row.originalExpenseId
await this.repository.operation(tx, row.userId, row.originalExpenseId) : ? await this.repository.operation(tx, row.userId, row.originalExpenseId)
null; : null;
const categoryId = source?.categoryId ?? row.categoryId; const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId ? const category = categoryId
await tx.expenseCategory.findFirst({ ? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId }, where: { id: categoryId, userId: row.userId },
}) : })
null; : null;
const refunds = const refunds =
row.kind === BankOperationKind.Expense || source ? row.kind === BankOperationKind.Expense || source
await tx.bankCardOperation.findMany({ ? await tx.bankCardOperation.findMany({
where: { where: {
userId: row.userId, userId: row.userId,
originalExpenseId: source?.id ?? row.id, originalExpenseId: source?.id ?? row.id,
deletedAt: null, deletedAt: null,
...(source ? { id: { not: row.id } } : {}), ...(source ? { id: { not: row.id } } : {}),
}, },
}) : })
[]; : [];
return { return {
id: row.id, id: row.id,
kind: row.kind as BankOperationDto['kind'], kind: row.kind as BankOperationDto['kind'],
@@ -78,12 +78,12 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId, originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks), amount: rubMoney(row.amountKopecks),
availableRefund: availableRefund:
row.kind === BankOperationKind.Expense || source ? row.kind === BankOperationKind.Expense || source
rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) : )
null, : null,
date: row.date, date: row.date,
description: row.description, description: row.description,
version: row.version, version: row.version,
@@ -336,9 +336,9 @@ export class BankOperationsService {
query: BankOperationsQueryDto | BankPageQueryDto, query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false, expenses = false,
) { ) {
const period = expenses ? const period = expenses
undefined : ? undefined
periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
@@ -347,9 +347,9 @@ export class BankOperationsService {
userId, userId,
deletedAt: null, deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(period ? ...(period
{ date: { gte: period.from, lte: period.to } } : ? { date: { gte: period.from, lte: period.to } }
{ kind: BankOperationKind.Expense }), : { kind: BankOperationKind.Expense }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -81,13 +81,13 @@ export function bankSummary(
continue; continue;
} }
const expense = const expense =
operation.kind === BankOperationKind.Expense ? operation.kind === BankOperationKind.Expense
operation.amountKopecks : ? operation.amountKopecks
ZERO_KOPECKS; : ZERO_KOPECKS;
const refund = const refund =
operation.kind === BankOperationKind.Refund ? operation.kind === BankOperationKind.Refund
operation.amountKopecks : ? operation.amountKopecks
ZERO_KOPECKS; : ZERO_KOPECKS;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
@@ -40,9 +40,9 @@ export class BankCardsRepository {
}); });
} catch (error) { } catch (error) {
const code = const code =
error instanceof Prisma.PrismaClientKnownRequestError ? error instanceof Prisma.PrismaClientKnownRequestError
error.code : ? error.code
''; : '';
const busy = const busy =
code === 'P2034' || code === 'P2034' ||
code === 'P2028' || code === 'P2028' ||
@@ -12,15 +12,15 @@ export class BrokerageAccountsService {
async list(userId: string, observe = false) { async list(userId: string, observe = false) {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all', observe);
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' ? saved.membership.availability === 'missing'
null : ? null
saved.accounts.map((account) => ({ : saved.accounts.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
type: account.type as BrokerageAccountDto['type'], type: account.type as BrokerageAccountDto['type'],
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot ? portfolioSummary: account.snapshot
{ ? {
totalsByCurrency: account.snapshot.totalsByCurrency, totalsByCurrency: account.snapshot.totalsByCurrency,
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
@@ -28,8 +28,8 @@ export class BrokerageAccountsService {
bondPositionCount: account.snapshot.positions.filter( bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND', (position) => position.type === 'BOND',
).length, ).length,
} : }
null, : null,
})); }));
return { return {
data, data,
@@ -47,18 +47,18 @@ export class BrokerageAccountsService {
) )
.digest('base64url'), .digest('base64url'),
availability: availability:
data === null ? data === null
'missing' : ? 'missing'
saved.accounts.some((account) => !account.snapshot) ? : saved.accounts.some((account) => !account.snapshot)
'partial' : ? 'partial'
'complete', : 'complete',
fetchedAt: saved.membership.lastSuccessfulAt ? fetchedAt: saved.membership.lastSuccessfulAt
new Date(saved.membership.lastSuccessfulAt) : ? new Date(saved.membership.lastSuccessfulAt)
null, : null,
freshForMs: 300_000, freshForMs: 300_000,
work: saved.membership.sync.id ? work: saved.membership.sync.id
{ ...saved.membership.sync, id: saved.membership.sync.id } : ? { ...saved.membership.sync, id: saved.membership.sync.id }
null, : null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
}; };
@@ -96,9 +96,9 @@ export class IncomeAnalyticsService {
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' }, { ...prior.meta, role: 'previous' },
{ {
...(plan.yearAgo && year ? ...(plan.yearAgo && year
year.meta : ? year.meta
investmentResourceMeta({ : investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable', dataRevision: 'unavailable',
availability: 'missing', availability: 'missing',
@@ -119,9 +119,9 @@ export class IncomeAnalyticsService {
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision, dataRevision: current.meta.dataRevision,
availability: current.meta.availability, availability: current.meta.availability,
fetchedAt: current.meta.lastSuccessfulAt ? fetchedAt: current.meta.lastSuccessfulAt
new Date(current.meta.lastSuccessfulAt) : ? new Date(current.meta.lastSuccessfulAt)
null, : null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage, coverage: current.meta.coverage,
@@ -144,13 +144,13 @@ export class IncomeAnalyticsService {
current.meta.lastSuccessfulAt, current.meta.lastSuccessfulAt,
), ),
previous: previous:
prior.items === null ? prior.items === null
null : ? null
comparison(selected, plan.previous, qualifying(prior.items)), : comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison: yearComparison:
plan.yearAgo && year?.items !== null && year ? plan.yearAgo && year?.items !== null && year
comparison(selected, plan.yearAgo, qualifying(year.items)) : ? comparison(selected, plan.yearAgo, qualifying(year.items))
null, : null,
bucketGranularity: plan.granularity, bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected), buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected), instruments: instruments(selected),
+21 -21
View File
@@ -39,24 +39,24 @@ export function planIncomePeriod(
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected) ? const previous = isFullMonth(selected)
previousMonth(selected.from) : ? previousMonth(selected.from)
shiftDays(selected, -rangeDays(selected)); : shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected); const candidate = yearAgo(selected);
const yearAgoRange = const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ? candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
candidate : ? candidate
null; : null;
return { return {
selected, selected,
previous, previous,
yearAgo: yearAgoRange, yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ? rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
'day' : ? 'day'
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ? : rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
'week' : ? 'week'
'month', : 'month',
}; };
} }
@@ -69,20 +69,20 @@ export function rangeBuckets(
} }
const buckets: DateOnlyRange[] = []; const buckets: DateOnlyRange[] = [];
let cursor = let cursor =
granularity === 'week' ? granularity === 'week'
addDays( ? addDays(
range.from, range.from,
-( -(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
DAYS_PER_WEEK DAYS_PER_WEEK
), ),
) : )
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; : `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' ? granularity === 'week'
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : ? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
endOfMonth(cursor); : endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
@@ -153,9 +153,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
result : ? result
null; : null;
}; };
const from = shift(range.from); const from = shift(range.from);
const to = shift(range.to); const to = shift(range.to);
@@ -70,9 +70,9 @@ function rangeFor(
if (!history) { if (!history) {
throw new ServiceUnavailableException({ throw new ServiceUnavailableException({
code: code:
input.instrumentType === 'share' ? input.instrumentType === 'share'
'STOCK_HISTORY_START_UNAVAILABLE' : ? 'STOCK_HISTORY_START_UNAVAILABLE'
'BOND_HISTORY_START_UNAVAILABLE', : 'BOND_HISTORY_START_UNAVAILABLE',
}); });
} }
from.setTime(utcDay(new Date(history)).valueOf()); from.setTime(utcDay(new Date(history)).valueOf());
@@ -114,9 +114,9 @@ export class CandleService {
throw new NotFoundException(); throw new NotFoundException();
} }
const now = new Date(); const now = new Date();
const range = detail.instrument ? const range = detail.instrument
rangeFor(input, detail.instrument, now) : ? rangeFor(input, detail.instrument, now)
null; : null;
const saved = range ? await this.repository.read(input.uid, range) : null; const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
if (!observe && (!saved || gaps.length)) { if (!observe && (!saved || gaps.length)) {
@@ -139,14 +139,14 @@ export class CandleService {
}); });
return { return {
data: data:
!saved || !covered ? !saved || !covered
null : ? null
{ : {
candles: saved.data, candles: saved.data,
currency: currency:
input.instrumentType === 'share' ? input.instrumentType === 'share'
detail.instrument?.currency?.toUpperCase() || null : ? detail.instrument?.currency?.toUpperCase() || null
null, : null,
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
observedAt: fetchedAt?.toISOString() ?? now.toISOString(), observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
}, },
@@ -159,8 +159,8 @@ export class CandleService {
work, work,
coverage: { coverage: {
requested: range ? [publicRange(range)] : [], requested: range ? [publicRange(range)] : [],
completeRanges: range ? completeRanges: range
completeCoverage.flatMap((row) => { ? completeCoverage.flatMap((row) => {
const from = new Date( const from = new Date(
Math.max(row.from.valueOf(), range.from.valueOf()), Math.max(row.from.valueOf(), range.from.valueOf()),
); );
@@ -168,22 +168,22 @@ export class CandleService {
Math.min(row.to.valueOf(), range.to.valueOf()), Math.min(row.to.valueOf(), range.to.valueOf()),
); );
return from < to ? [publicRange({ from, to })] : []; return from < to ? [publicRange({ from, to })] : [];
}) : })
[], : [],
missingRanges: missing.map(publicRange), missingRanges: missing.map(publicRange),
asOf: fetchedAt?.toISOString() ?? null, asOf: fetchedAt?.toISOString() ?? null,
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
'known' : ? 'known'
'unknown', : 'unknown',
historyState: complete ? historyState: complete
'complete' : ? 'complete'
work?.state === 'failed' ? : work?.state === 'failed'
'failed' : ? 'failed'
work?.state === 'running' ? : work?.state === 'running'
'loading' : ? 'loading'
covered ? : covered
'partial' : ? 'partial'
'missing', : 'missing',
}, },
dependencies: [ dependencies: [
detail.meta, detail.meta,
@@ -193,9 +193,9 @@ export class CandleService {
dataRevision: String(row.dataRevision), dataRevision: String(row.dataRevision),
availability: 'complete', availability: 'complete',
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
freshForMs: row.isComplete ? freshForMs: row.isComplete
Number.POSITIVE_INFINITY : ? Number.POSITIVE_INFINITY
tailFreshForMs, : tailFreshForMs,
work: null, work: null,
}), }),
), ),
@@ -291,9 +291,9 @@ export class CandleService {
) { ) {
const input = { uid, instrumentType, period: 'all' as const }; const input = { uid, instrumentType, period: 'all' as const };
const range = const range =
detail?.instrument && history ? detail?.instrument && history
rangeFor(input, detail.instrument, now) : ? rangeFor(input, detail.instrument, now)
null; : null;
if ( if (
!range || !range ||
candleGaps( candleGaps(
@@ -79,14 +79,14 @@ export class CatalogDiscoveryService {
async search(userId: string, input: CatalogQuery, observe = false) { async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe); const discovery = await this.read(userId, input, observe);
const sources = discovery.data ? const sources = discovery.data
await this.enrichment.readMany( ? await this.enrichment.readMany(
userId, userId,
input.instrumentType, input.instrumentType,
discovery.data.map((item) => item.uid), discovery.data.map((item) => item.uid),
observe, observe,
) : )
[]; : [];
const dependencies = [ const dependencies = [
discovery.meta, discovery.meta,
...sources.map((source) => source.meta), ...sources.map((source) => source.meta),
@@ -114,22 +114,22 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: availability:
discovery.data === null ? discovery.data === null
'missing' : ? 'missing'
sources.every((source) => source.meta.availability === 'complete') ? : sources.every((source) => source.meta.availability === 'complete')
'complete' : ? 'complete'
'partial', : 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt ? fetchedAt: discovery.meta.lastSuccessfulAt
new Date(discovery.meta.lastSuccessfulAt) : ? new Date(discovery.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id ? work: discovery.meta.sync.id
{ ? {
id: discovery.meta.sync.id, id: discovery.meta.sync.id,
state: discovery.meta.sync.state, state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode, errorCode: discovery.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
@@ -177,17 +177,17 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: source.meta.availability, availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt ? fetchedAt: source.meta.lastSuccessfulAt
new Date(source.meta.lastSuccessfulAt) : ? new Date(source.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id ? work: source.meta.sync.id
{ ? {
id: source.meta.sync.id, id: source.meta.sync.id,
state: source.meta.sync.state, state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode, errorCode: source.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
@@ -208,36 +208,36 @@ export class CatalogDiscoveryService {
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' ? instrumentType === 'share'
'STOCK_TICKER_AMBIGUOUS' : ? 'STOCK_TICKER_AMBIGUOUS'
'BOND_TICKER_AMBIGUOUS', : 'BOND_TICKER_AMBIGUOUS',
}); });
} }
const result = lookup.data ? const result = lookup.data
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
null; : null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return { return {
data: result?.data ? data: result?.data
{ ...result.data, slug: ticker.trim().toUpperCase() } : ? { ...result.data, slug: ticker.trim().toUpperCase() }
null, : null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: result?.meta.availability ?? 'missing', availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt ? fetchedAt: lookup.meta.lastSuccessfulAt
new Date(lookup.meta.lastSuccessfulAt) : ? new Date(lookup.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id ? work: lookup.meta.sync.id
{ ? {
id: lookup.meta.sync.id, id: lookup.meta.sync.id,
state: lookup.meta.sync.state, state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode, errorCode: lookup.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
@@ -334,19 +334,19 @@ export class CatalogDiscoveryService {
const token = await this.access.tokenForAttempt(attempt); const token = await this.access.tokenForAttempt(attempt);
if (token) { if (token) {
const found = const found =
input.instrumentType === 'share' ? input.instrumentType === 'share'
await this.tbank.findShares(token, input.query) : ? await this.tbank.findShares(token, input.query)
await this.tbank.findBonds(token, input.query); : await this.tbank.findBonds(token, input.query);
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
input, input,
selectCatalogInstruments( selectCatalogInstruments(
input.exactTicker ? input.exactTicker
found.filter( ? found.filter(
(item) => (item) =>
item.ticker?.trim().toUpperCase() === input.query, item.ticker?.trim().toUpperCase() === input.query,
) : )
found, : found,
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice( ).slice(
@@ -124,9 +124,9 @@ export class CatalogEnrichmentService {
range, range,
); );
} }
const work = inputs.length ? const work = inputs.length
await this.repository.workMany(userId, revision, inputs) : ? await this.repository.workMany(userId, revision, inputs)
[]; : [];
const byScope = new Map(work.map((row) => [row.scopeKey, row])); const byScope = new Map(work.map((row) => [row.scopeKey, row]));
return inputs.map((input) => { return inputs.map((input) => {
const row = rows.find( const row = rows.find(
@@ -136,16 +136,16 @@ export class CatalogEnrichmentService {
const payload = row?.payload as CatalogPayload | undefined; const payload = row?.payload as CatalogPayload | undefined;
return { return {
input, input,
version: row ? version: row
{ ? {
instrumentUid: row.instrumentUid, instrumentUid: row.instrumentUid,
source: row.source, source: row.source,
kind: row.kind, kind: row.kind,
rangeKey: row.rangeKey, rangeKey: row.rangeKey,
dataRevision: row.dataRevision, dataRevision: row.dataRevision,
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
} : }
null, : null,
payload, payload,
instrument: payload?.instrument ?? null, instrument: payload?.instrument ?? null,
price: payload?.price ?? null, price: payload?.price ?? null,
@@ -177,9 +177,9 @@ export class CatalogEnrichmentService {
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => { const reservation = this.reservations.then(async () => {
const rows = inputs.length ? const rows = inputs.length
await this.repository.readSources(inputs) : ? await this.repository.readSources(inputs)
[]; : [];
const ids: string[] = []; const ids: string[] = [];
for (const input of inputs) { for (const input of inputs) {
const row = rows.find( const row = rows.find(
@@ -242,18 +242,18 @@ export class CatalogEnrichmentService {
observe = false, observe = false,
) { ) {
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
section === 'company' ? section === 'company'
['detail', 'description', 'fundamentals'] : ? ['detail', 'description', 'fundamentals']
[ : [
...(instrumentType === 'bond' ? ...(instrumentType === 'bond'
['bond-current' as const] : ? ['bond-current' as const]
['detail' as const]), : ['detail' as const]),
'last-price', 'last-price',
'trading-status', 'trading-status',
'comparison', 'comparison',
...(section === 'metrics' ? ...(section === 'metrics'
['coupons' as const, 'yield' as const, 'rating' as const] : ? ['coupons' as const, 'yield' as const, 'rating' as const]
[]), : []),
]; ];
const sources = await this.readMany( const sources = await this.readMany(
userId, userId,
@@ -279,18 +279,18 @@ export class CatalogEnrichmentService {
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: !first.instrument ? availability: !first.instrument
'missing' : ? 'missing'
complete ? : complete
'complete' : ? 'complete'
'partial', : 'partial',
fetchedAt: first.meta.lastSuccessfulAt ? fetchedAt: first.meta.lastSuccessfulAt
new Date(first.meta.lastSuccessfulAt) : ? new Date(first.meta.lastSuccessfulAt)
null, : null,
freshForMs: freshForMs:
section === 'company' ? section === 'company'
tbankSourceTtlMs.company : ? tbankSourceTtlMs.company
tbankSourceTtlMs.quote, : tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -315,9 +315,9 @@ export class CatalogEnrichmentService {
): Promise<CatalogPayload> { ): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') { if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument = const instrument =
input.instrumentType === 'share' ? input.instrumentType === 'share'
await this.tbank.getShare(token, input.uid) : ? await this.tbank.getShare(token, input.uid)
await this.tbank.getBond(token, input.uid); : await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) { if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch'); throw new Error('Catalog UID mismatch');
} }
@@ -400,9 +400,9 @@ export class CatalogEnrichmentService {
} }
const details = await this.repository.readSources([ const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined }, { ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond' ? ...(input.instrumentType === 'bond'
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
[]), : []),
]); ]);
const instrument = details const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument) .map((row) => (row.payload as CatalogPayload).instrument)
@@ -415,36 +415,36 @@ export class CatalogEnrichmentService {
} }
if (input.kind === 'description') { if (input.kind === 'description') {
return { return {
description: instrument?.assetUid ? description: instrument?.assetUid
await this.tbank.getAssetDescription(token, instrument?.assetUid) : ? await this.tbank.getAssetDescription(token, instrument?.assetUid)
null, : null,
}; };
} }
if (input.kind === 'fundamentals') { if (input.kind === 'fundamentals') {
return { return {
fundamentals: instrument?.assetUid ? fundamentals: instrument?.assetUid
await this.tbank.getFundamentals(token, instrument?.assetUid) : ? await this.tbank.getFundamentals(token, instrument?.assetUid)
null, : null,
}; };
} }
if (input.kind === 'coupons' || input.kind === 'dividends') { if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey); const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[') ? const [from, to] = input.rangeKey?.startsWith('[')
(JSON.parse(input.rangeKey) as [string, string]) : ? (JSON.parse(input.rangeKey) as [string, string])
[ : [
`${year - payoutHistoryYears}-01-01`, `${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`, `${year + payoutFutureYears}-01-01`,
]; ];
return input.kind === 'coupons' ? return input.kind === 'coupons'
{ ? {
coupons: await this.tbank.getBondCoupons( coupons: await this.tbank.getBondCoupons(
token, token,
input.uid, input.uid,
from, from,
to, to,
), ),
} : }
{ : {
dividends: await this.tbank.getDividends( dividends: await this.tbank.getDividends(
token, token,
input.uid, input.uid,
@@ -461,20 +461,20 @@ export class CatalogEnrichmentService {
if (input.kind === 'yield') { if (input.kind === 'yield') {
return { return {
effectiveYield: effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode ? instrument?.ticker && instrument.isin && instrument.classCode
await this.moex.bondYield( ? await this.moex.bondYield(
instrument.ticker, instrument.ticker,
instrument.isin, instrument.isin,
instrument.classCode, instrument.classCode,
) : )
null, : null,
}; };
} }
if (input.kind === 'rating') { if (input.kind === 'rating') {
return { return {
creditRating: instrument?.isin ? creditRating: instrument?.isin
await this.ratings.rating(instrument.isin) : ? await this.ratings.rating(instrument.isin)
null, : null,
}; };
} }
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
@@ -533,17 +533,17 @@ export class CatalogEnrichmentService {
prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
null; null;
const value = const value =
item.input.kind === 'last-price' ? item.input.kind === 'last-price'
{ ? {
price: price ? price: price
{ ? {
instrumentUid: item.input.uid, instrumentUid: item.input.uid,
price: price.price, price: price.price,
time: price.time, time: price.time,
} : }
null, : null,
} : }
payload; : payload;
// Keep accepting observers while publication awaits SQLite. // Keep accepting observers while publication awaits SQLite.
item.observers.unshift({ attempt: item.attempt, input: item.input }); item.observers.unshift({ attempt: item.attempt, input: item.input });
for (const observer of item.observers) { for (const observer of item.observers) {
@@ -596,17 +596,17 @@ function sourceInputs(
uid, uid,
kind, kind,
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
{ ticker: range?.tickers?.[uid] || range?.ticker } : ? { ticker: range?.tickers?.[uid] || range?.ticker }
{}), : {}),
...(['coupons', 'dividends'].includes(kind) ? ...(['coupons', 'dividends'].includes(kind)
{ ? {
rangeKey: range ? rangeKey: range
JSON.stringify([range.from, range.to]) : ? JSON.stringify([range.from, range.to])
String(new Date().getUTCFullYear()), : String(new Date().getUTCFullYear()),
} : }
{}), : {}),
})), })),
); );
} }
@@ -679,9 +679,9 @@ function sourcePolicy(kind: CatalogSource['kind']) {
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
(input.kind === 'detail' || input.kind === 'bond-current') ? (input.kind === 'detail' || input.kind === 'bond-current')
`bond-metadata:${input.uid}` : ? `bond-metadata:${input.uid}`
catalogScope(input); : catalogScope(input);
} }
const quotationScale = 9; const quotationScale = 9;
@@ -9,9 +9,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
): (T & { uid: string })[] { ): (T & { uid: string })[] {
const seen = new Set<string>(); const seen = new Set<string>();
const kind = const kind =
instrumentType === 'share' ? instrumentType === 'share'
'INSTRUMENT_TYPE_SHARE' : ? 'INSTRUMENT_TYPE_SHARE'
'INSTRUMENT_TYPE_BOND'; : 'INSTRUMENT_TYPE_BOND';
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found return found
.filter( .filter(
@@ -29,9 +29,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
a.uid.localeCompare(b.uid), a.uid.localeCompare(b.uid),
) )
.filter((instrument) => { .filter((instrument) => {
const key = instrument.isin?.trim() ? const key = instrument.isin?.trim()
`isin:${instrument.isin.trim().toUpperCase()}` : ? `isin:${instrument.isin.trim().toUpperCase()}`
`uid:${instrument.uid}`; : `uid:${instrument.uid}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
const MOSCOW_OFFSET_MS = 10_800_000; const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): null | string { export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
null; : null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
@@ -43,8 +43,8 @@ export function instrumentPosition(
currentValue, currentValue,
absoluteReturn, absoluteReturn,
averagePurchasePrice: averagePurchasePrice:
positive && totalCost ? positive && totalCost
{ ? {
currency: totalCost.currency, currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -53,15 +53,15 @@ export function instrumentPosition(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} : }
null, : null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({ accounts: positions.map((p) => ({
...p, ...p,
returnPercent: returnPercent:
decimalFromString(p.quantity).value > ZERO_QUANTITY ? decimalFromString(p.quantity).value > ZERO_QUANTITY
percent(p.absoluteReturn, cost(p)) : ? percent(p.absoluteReturn, cost(p))
null, : null,
})), })),
isStale: portfolio.isStale, isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt, observedAt: portfolio.lastSuccessfulAt,
@@ -69,8 +69,8 @@ export function instrumentPosition(
} }
function cost(position: PortfolioPositionDto): MoneyDto | null { function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice; const price = position.averagePurchasePrice;
return price ? return price
{ ? {
currency: price.currency, currency: price.currency,
value: formatDecimal( value: formatDecimal(
multiply( multiply(
@@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
decimalFromString(position.quantity), decimalFromString(position.quantity),
), ),
), ),
} : }
null; : null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency; const currency = values[0]?.currency;
@@ -37,18 +37,18 @@ export class BondCatalogService {
data: data:
result.data?.map((item) => { result.data?.map((item) => {
const short = toBond(item); const short = toBond(item);
const detail = item.detail ? const detail = item.detail
toBond({ ...item.detail, uid: item.uid }) : ? toBond({ ...item.detail, uid: item.uid })
short; : short;
const last = item.price; const last = item.price;
return { return {
...detail, ...detail,
classCode: detail.classCode ?? short.classCode, classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin, isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable, apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ? pricePercent: last?.price
formatDecimal(decimal(last.price)) : ? formatDecimal(decimal(last.price))
null, : null,
priceAt: timestamp(last?.time), priceAt: timestamp(last?.time),
}; };
}) ?? null, }) ?? null,
@@ -63,9 +63,9 @@ export class BondCatalogService {
const result = await this.discovery.detail(userId, 'bond', uid, observe); const result = await this.discovery.detail(userId, 'bond', uid, observe);
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...toBond(result.data), slug: result.data.slug } : ? { ...toBond(result.data), slug: result.data.slug }
null, : null,
}; };
} }
@@ -82,9 +82,9 @@ export class BondCatalogService {
); );
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...toBond(result.data), slug: result.data.slug } : ? { ...toBond(result.data), slug: result.data.slug }
null, : null,
}; };
} }
} }
@@ -62,9 +62,9 @@ export class BondIncomeService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
bondMetrics(result.values, result.unavailable, result.observedAt) : ? bondMetrics(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -99,18 +99,18 @@ export class BondIncomeService {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
const position = portfolio.data ? const position = portfolio.data
instrumentPosition(portfolio.data, uid, 'BOND') : ? instrumentPosition(portfolio.data, uid, 'BOND')
null; : null;
const projected = events ? const projected = events
bondCoupons( ? bondCoupons(
events, events,
position, position,
{ from, to, today }, { from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data, !portfolio.data,
) : )
undefined; : undefined;
const result = await this.forecasts.result({ const result = await this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -121,9 +121,9 @@ export class BondIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
return result.data || !projected ? return result.data || !projected
result : ? result
{ : {
data: bondCoupons( data: bondCoupons(
events ?? [], events ?? [],
null, null,
@@ -146,19 +146,19 @@ export function bondCoupons(
const { from, to, today } = window; const { from, to, today } = window;
const quantity = const quantity =
position?.accounts.length && position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
decimalFromString(position.quantity) : ? decimalFromString(position.quantity)
null; : null;
const coupons = events const coupons = events
.map((event) => { .map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perBond = money(event.payOneBond); const perBond = money(event.payOneBond);
const timing = !paymentDate ? const timing = !paymentDate
('undated' as const) : ? ('undated' as const)
paymentDate >= today ? : paymentDate >= today
('upcoming' as const) : ? ('upcoming' as const)
('past' as const); : ('past' as const);
return { return {
number: event.couponNumber ?? null, number: event.couponNumber ?? null,
paymentDate, paymentDate,
@@ -172,14 +172,14 @@ export function bondCoupons(
perBond && perBond &&
timing === 'upcoming' && timing === 'upcoming' &&
recordDate && recordDate &&
recordDate >= today ? recordDate >= today
{ ? {
currency: perBond.currency, currency: perBond.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
} : }
null, : null,
}; };
}) })
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
@@ -225,8 +225,8 @@ export function bondMetrics(
clean && clean &&
coupon.currency === clean.currency && coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT ? decimalFromString(clean.value).value > ZERO_AMOUNT
formatDecimal( ? formatDecimal(
divide( divide(
multiply( multiply(
decimalFromString(coupon.value), decimalFromString(coupon.value),
@@ -238,14 +238,14 @@ export function bondMetrics(
), ),
COUPON_YIELD_DECIMAL_PLACES, COUPON_YIELD_DECIMAL_PLACES,
), ),
) : )
null; : null;
const couponType = const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
next.couponType : ? next.couponType
instrument.floatingCouponFlag === true ? : instrument.floatingCouponFlag === true
'COUPON_TYPE_FLOATING' : ? 'COUPON_TYPE_FLOATING'
null; : null;
return { return {
creditRating, creditRating,
coupon, coupon,
@@ -48,9 +48,9 @@ export class BondMarketService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
bondQuote(result.values, result.unavailable, result.observedAt) : ? bondQuote(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -83,8 +83,8 @@ export function bondQuote(
const cleanPrice = const cleanPrice =
pricePercent !== null && pricePercent !== null &&
nominal && nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT ? decimalFromString(nominal.value).value > ZERO_AMOUNT
{ ? {
currency: nominal.currency, currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -96,11 +96,11 @@ export function bondQuote(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} : }
null; : null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency ? cleanPrice && aci && aci.currency === cleanPrice.currency
{ ? {
currency: cleanPrice.currency, currency: cleanPrice.currency,
value: formatDecimal( value: formatDecimal(
add( add(
@@ -108,8 +108,8 @@ export function bondQuote(
decimalFromString(aci.value), decimalFromString(aci.value),
), ),
), ),
} : }
null; : null;
return { return {
pricePercent, pricePercent,
cleanPrice, cleanPrice,
@@ -118,9 +118,9 @@ export function bondQuote(
priceAt, priceAt,
tradingStatus, tradingStatus,
changePercentagePoints: changePercentagePoints:
last?.price && previous?.close ? last?.price && previous?.close
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : ? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
null, : null,
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
unavailable, unavailable,
observedAt, observedAt,
@@ -123,9 +123,9 @@ describe('bond financial projections', () => {
{ {
couponDate: time('2026-10-10'), couponDate: time('2026-10-10'),
payOneBond: payOneBond:
scenario === 'unknown' ? scenario === 'unknown'
undefined : ? undefined
money('30', scenario === 'currency' ? 'usd' : 'rub'), : money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90, couponPeriod: scenario === 'period' ? 0 : 90,
}, },
], ],
@@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) { export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase(); const currency = value?.currency?.toUpperCase();
return value && currency ? return value && currency
{ currency, value: formatDecimal(decimal(value)) } : ? { currency, value: formatDecimal(decimal(value)) }
null; : null;
} }
@@ -38,9 +38,9 @@ export function candleGaps(
gaps = gaps.flatMap((gap) => { gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [ return [
...(covered.from > gap.from ? ...(covered.from > gap.from
[{ from: gap.from, to: covered.from }] : ? [{ from: gap.from, to: covered.from }]
[]), : []),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
]; ];
}); });
@@ -100,14 +100,14 @@ export class CandleRepository {
priority: nextPriority, priority: nextPriority,
intent: { intent: {
...previousIntent, ...previousIntent,
...(intent.force ? ...(intent.force
{ ? {
force: true, force: true,
...(intent.now && intent.now > previousNow ? ...(intent.now && intent.now > previousNow
{ now: intent.now } : ? { now: intent.now }
{}), : {}),
} : }
{}), : {}),
}, },
}, },
}); });
@@ -263,12 +263,12 @@ export class CandleRepository {
// Preserve coverage outside the requested reconciliation boundaries. // Preserve coverage outside the requested reconciliation boundaries.
for (const previous of old) { for (const previous of old) {
for (const part of [ for (const part of [
...(previous.from < range.from ? ...(previous.from < range.from
[{ from: previous.from, to: range.from }] : ? [{ from: previous.from, to: range.from }]
[]), : []),
...(previous.to > range.to ? ...(previous.to > range.to
[{ from: range.to, to: previous.to }] : ? [{ from: range.to, to: previous.to }]
[]), : []),
]) { ]) {
await tx.tbankCandleCoverage.create({ await tx.tbankCandleCoverage.create({
data: { ...previous, ...part }, data: { ...previous, ...part },
@@ -66,8 +66,8 @@ export class CatalogDiscoveryRepository {
read(input: CatalogQuery) { read(input: CatalogQuery) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const alias = input.exactTicker ? const alias = input.exactTicker
await tx.tbankInstrumentLookup.findUnique({ ? await tx.tbankInstrumentLookup.findUnique({
where: { where: {
provider_identifierType_identifier: { provider_identifierType_identifier: {
provider, provider,
@@ -75,17 +75,17 @@ export class CatalogDiscoveryRepository {
identifier: input.query, identifier: input.query,
}, },
}, },
}) : })
null; : null;
const discovery = input.exactTicker ? const discovery = input.exactTicker
alias ? ? alias
{ ? {
instrumentUids: alias.matchedUids, instrumentUids: alias.matchedUids,
fetchedAt: alias.fetchedAt, fetchedAt: alias.fetchedAt,
dataRevision: alias.dataRevision, dataRevision: alias.dataRevision,
} : }
null : : null
await tx.tbankCatalogDiscovery.findUnique({ : await tx.tbankCatalogDiscovery.findUnique({
where: { where: {
provider_instrumentType_query: { provider_instrumentType_query: {
provider, provider,
@@ -134,9 +134,9 @@ export class CatalogDiscoveryRepository {
kind: input.kind, kind: input.kind,
source: sourceProvider(input), source: sourceProvider(input),
rangeKey: rangeKey:
input.kind === 'detail' ? input.kind === 'detail'
input.instrumentType : ? input.instrumentType
(input.rangeKey ?? ''), : (input.rangeKey ?? ''),
})), })),
}, },
}); });
@@ -232,9 +232,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previousAlias?.matchedUids, previousAlias?.matchedUids,
matchedUids, matchedUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -246,9 +246,9 @@ export class CatalogDiscoveryRepository {
source: sourceProvider(input), source: sourceProvider(input),
kind: input.kind, kind: input.kind,
rangeKey: rangeKey:
input.kind === 'detail' ? input.kind === 'detail'
input.instrumentType : ? input.instrumentType
(input.rangeKey ?? ''), : (input.rangeKey ?? ''),
}; };
const previous = await tx.tbankInstrumentSource.findUnique({ const previous = await tx.tbankInstrumentSource.findUnique({
where: { provider_instrumentUid_source_kind_rangeKey: key }, where: { provider_instrumentUid_source_kind_rangeKey: key },
@@ -359,9 +359,9 @@ export class CatalogDiscoveryRepository {
instrumentKind: instrument.instrumentKind, instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency, currency: instrument.currency,
brand: instrument.brand ? brand: instrument.brand
{ logoName: instrument.brand.logoName } : ? { logoName: instrument.brand.logoName }
undefined, : undefined,
}), }),
) as Prisma.InputJsonObject; ) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({ await tx.tbankInstrument.upsert({
@@ -418,9 +418,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.matchedUids, previous?.matchedUids,
instrumentUids, instrumentUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -444,9 +444,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.instrumentUids, previous?.instrumentUids,
instrumentUids, instrumentUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -480,9 +480,9 @@ export class CatalogDiscoveryRepository {
} }
function sourceProvider(input: CatalogSource) { function sourceProvider(input: CatalogSource) {
return input.kind === 'yield' || input.kind === 'bond-events' ? return input.kind === 'yield' || input.kind === 'bond-events'
'moex' : ? 'moex'
input.kind === 'rating' ? : input.kind === 'rating'
'ncr' : ? 'ncr'
'tbank'; : 'tbank';
} }
@@ -23,8 +23,8 @@ export class GetStockCompanyUseCase {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
{ ? {
description: result.values.description ?? null, description: result.values.description ?? null,
metrics: metrics( metrics: metrics(
result.values.fundamentals ?? null, result.values.fundamentals ?? null,
@@ -32,8 +32,8 @@ export class GetStockCompanyUseCase {
), ),
unavailable: result.unavailable, unavailable: result.unavailable,
observedAt: result.observedAt, observedAt: result.observedAt,
} : }
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -51,22 +51,22 @@ export function metrics(
][] = [ ][] = [
['marketCapitalization', 'money', null], ['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'], ['peRatioTtm', 'ratio', 'TTM'],
...(bank ? ...(bank
([ ? ([
['priceToBookTtm', 'ratio', 'TTM'], ['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null], ['roe', 'percent', null],
] as typeof selection) : ] as typeof selection)
([ : ([
['revenueTtm', 'money', 'TTM'], ['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)), ] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'], ['netIncomeTtm', 'money', 'TTM'],
...(!bank ? ...(!bank
([ ? ([
['totalDebtMrq', 'money', 'MRQ'], ['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null], ['netDebtToEbitda', 'ratio', null],
] as typeof selection) : ] as typeof selection)
[]), : []),
]; ];
return selection.flatMap(([key, unit, period]) => { return selection.flatMap(([key, unit, period]) => {
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
@@ -74,8 +74,8 @@ export function metrics(
const currency = values.currency?.toUpperCase() || null; const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' && return typeof value === 'number' &&
Number.isFinite(value) && Number.isFinite(value) &&
(unit !== 'money' || currency) ? (unit !== 'money' || currency)
[ ? [
{ {
key, key,
value, value,
@@ -83,7 +83,7 @@ export function metrics(
period, period,
currency: unit === 'money' ? currency : null, currency: unit === 'money' ? currency : null,
}, },
] : ]
[]; : [];
}); });
} }
@@ -34,9 +34,9 @@ export class StockCatalogService {
data: data:
result.data?.map((item) => { result.data?.map((item) => {
const short = stock(item); const short = stock(item);
const detail = item.detail ? const detail = item.detail
stock({ ...item.detail, uid: item.uid }) : ? stock({ ...item.detail, uid: item.uid })
short; : short;
const last = item.price; const last = item.price;
return { return {
...detail, ...detail,
@@ -44,12 +44,12 @@ export class StockCatalogService {
isin: detail.isin ?? short.isin, isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable, apiTradeAvailable: short.apiTradeAvailable,
price: price:
last?.price && detail.currency ? last?.price && detail.currency
{ ? {
currency: detail.currency, currency: detail.currency,
value: formatDecimal(decimal(last.price)), value: formatDecimal(decimal(last.price)),
} : }
null, : null,
priceAt: timestamp(last?.time), priceAt: timestamp(last?.time),
}; };
}) ?? null, }) ?? null,
@@ -64,9 +64,9 @@ export class StockCatalogService {
const result = await this.discovery.detail(userId, 'share', uid, observe); const result = await this.discovery.detail(userId, 'share', uid, observe);
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...stock(result.data), slug: result.data.slug } : ? { ...stock(result.data), slug: result.data.slug }
null, : null,
}; };
} }
@@ -83,9 +83,9 @@ export class StockCatalogService {
); );
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...stock(result.data), slug: result.data.slug } : ? { ...stock(result.data), slug: result.data.slug }
null, : null,
}; };
} }
} }
@@ -69,18 +69,18 @@ export class StockIncomeService {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.dividends; const events = sources[1].payload?.dividends;
const position = portfolio.data ? const position = portfolio.data
instrumentPosition(portfolio.data, uid, 'STOCK') : ? instrumentPosition(portfolio.data, uid, 'STOCK')
null; : null;
const projected = events ? const projected = events
stockDividends( ? stockDividends(
events, events,
position, position,
{ from, to, today }, { from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data, !portfolio.data,
) : )
undefined; : undefined;
const result = await this.forecasts.result({ const result = await this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -91,9 +91,9 @@ export class StockIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
return result.data || !projected ? return result.data || !projected
result : ? result
{ : {
data: stockDividends( data: stockDividends(
events ?? [], events ?? [],
null, null,
@@ -117,9 +117,9 @@ export function stockDividends(
const quantity = const quantity =
position && position &&
position.accounts.length && position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
decimalFromString(position.quantity) : ? decimalFromString(position.quantity)
null; : null;
const dividends: StockDividendDto[] = events const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => { .map((event) => {
@@ -128,29 +128,29 @@ export function stockDividends(
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase(); const currency = event.dividendNet?.currency?.toUpperCase();
const perShare = const perShare =
currency && event.dividendNet ? currency && event.dividendNet
{ currency, value: formatDecimal(decimal(event.dividendNet)) } : ? { currency, value: formatDecimal(decimal(event.dividendNet)) }
null; : null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate; const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate ? const timing: StockDividendDto['timing'] = eventDate
eventDate >= today ? ? eventDate >= today
'upcoming' : ? 'upcoming'
'past' : : 'past'
'undated'; : 'undated';
const estimatedAmount = const estimatedAmount =
quantity && quantity &&
perShare && perShare &&
recordDate && recordDate &&
recordDate >= today && recordDate >= today &&
timing === 'upcoming' ? timing === 'upcoming'
{ ? {
currency: perShare.currency, currency: perShare.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)), multiply(quantity, decimalFromString(perShare.value)),
), ),
} : }
null; : null;
return { return {
perShare, perShare,
recordDate, recordDate,
@@ -44,9 +44,9 @@ export class StockMarketService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
stockQuote(result.values, result.unavailable, result.observedAt) : ? stockQuote(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -75,20 +75,20 @@ export function stockQuote(
const priceAt = timestamp(last?.time); const priceAt = timestamp(last?.time);
const currency = share?.currency?.toUpperCase(); const currency = share?.currency?.toUpperCase();
const price = const price =
last?.price && currency ? last?.price && currency
{ currency, value: formatDecimal(decimal(last.price)) } : ? { currency, value: formatDecimal(decimal(last.price)) }
null; : null;
const previousPrice = const previousPrice =
previous?.close && currency ? previous?.close && currency
{ currency, value: formatDecimal(decimal(previous.close)) } : ? { currency, value: formatDecimal(decimal(previous.close)) }
null; : null;
const difference = const difference =
price && previousPrice ? price && previousPrice
subtract( ? subtract(
decimalFromString(price.value), decimalFromString(price.value),
decimalFromString(previousPrice.value), decimalFromString(previousPrice.value),
) : )
null; : null;
return { return {
price, price,
priceAt, priceAt,
@@ -96,21 +96,21 @@ export function stockQuote(
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
previousClose: previousPrice, previousClose: previousPrice,
change: change:
difference && currency ? difference && currency
{ currency, value: formatDecimal(difference) } : ? { currency, value: formatDecimal(difference) }
null, : null,
changePercent: changePercent:
difference && difference &&
previousPrice && previousPrice &&
decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? decimalFromString(previousPrice.value).value > ZERO_AMOUNT
formatDecimal( ? formatDecimal(
divide( divide(
multiply(difference, decimalFromString('100')), multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value), decimalFromString(previousPrice.value),
CHANGE_PERCENT_DECIMAL_PLACES, CHANGE_PERCENT_DECIMAL_PLACES,
), ),
) : )
null, : null,
unavailable, unavailable,
observedAt, observedAt,
}; };
@@ -16,11 +16,11 @@ export function investmentResourceMeta(input: {
const ownSync = { const ownSync = {
id: input.work?.id ?? null, id: input.work?.id ?? null,
state: state:
input.work?.state === 'running' ? input.work?.state === 'running'
('running' as const) : ? ('running' as const)
input.work?.state === 'failed' ? : input.work?.state === 'failed'
('failed' as const) : ? ('failed' as const)
('idle' as const), : ('idle' as const),
errorCode: input.work?.errorCode ?? null, errorCode: input.work?.errorCode ?? null,
}; };
const states = [ const states = [
@@ -34,11 +34,11 @@ export function investmentResourceMeta(input: {
states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'queued') ??
states.find((state) => state.state === 'failed') ?? states.find((state) => state.state === 'failed') ??
ownSync; ownSync;
const sourceFreshness = input.fetchedAt ? const sourceFreshness = input.fetchedAt
Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
('fresh' as const) : ? ('fresh' as const)
('stale' as const) : : ('stale' as const)
('unknown' as const); : ('unknown' as const);
const freshness = [ const freshness = [
sourceFreshness, sourceFreshness,
...(input.dependencies ?? []) ...(input.dependencies ?? [])
@@ -56,16 +56,16 @@ export function investmentResourceMeta(input: {
.sort(); .sort();
const meta = { const meta = {
resourceKey: input.resourceKey, resourceKey: input.resourceKey,
...(input.unavailableReason ? ...(input.unavailableReason
{ unavailableReason: input.unavailableReason } : ? { unavailableReason: input.unavailableReason }
{}), : {}),
dataRevision: input.dataRevision, dataRevision: input.dataRevision,
availability: input.availability, availability: input.availability,
freshness: freshness.includes('unknown') ? freshness: freshness.includes('unknown')
('unknown' as const) : ? ('unknown' as const)
freshness.includes('stale') ? : freshness.includes('stale')
('stale' as const) : ? ('stale' as const)
('fresh' as const), : ('fresh' as const),
lastSuccessfulAt: timestamps[0] ?? null, lastSuccessfulAt: timestamps[0] ?? null,
sync, sync,
...(input.coverage ? { coverage: input.coverage } : {}), ...(input.coverage ? { coverage: input.coverage } : {}),
@@ -79,9 +79,9 @@ export function investmentResourceMeta(input: {
...meta, ...meta,
// The requested window is identified by resourceKey. Its moving "now" // The requested window is identified by resourceKey. Its moving "now"
// boundary is not a change to saved coverage between status and GET. // boundary is not a change to saved coverage between status and GET.
coverage: meta.coverage ? coverage: meta.coverage
{ ...meta.coverage, requested: undefined } : ? { ...meta.coverage, requested: undefined }
undefined, : undefined,
}), }),
) )
.digest('base64url'), .digest('base64url'),
+43 -43
View File
@@ -84,16 +84,16 @@ export function planInvestmentScreen(
const unavailable: { reason: string; resourceKey: string }[] = []; const unavailable: { reason: string; resourceKey: string }[] = [];
const operations = (from: Date, to: Date, instrumentUid?: string) => { const operations = (from: Date, to: Date, instrumentUid?: string) => {
const targets = accounts.flatMap((account) => const targets = accounts.flatMap((account) =>
from < to ? from < to
[ ? [
{ {
accountId: account.accountId, accountId: account.accountId,
from, from,
to, to,
...(instrumentUid ? { instrumentUid } : {}), ...(instrumentUid ? { instrumentUid } : {}),
}, },
] : ]
[], : [],
); );
sources.push( sources.push(
...operationIntervals(targets).map((range) => ({ ...operationIntervals(targets).map((range) => ({
@@ -112,12 +112,12 @@ export function planInvestmentScreen(
})), })),
); );
const knownOpening = const knownOpening =
accounts.length && accounts.every((account) => account.openedOn) ? accounts.length && accounts.every((account) => account.openedOn)
new Date( ? new Date(
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
) : )
undefined; : undefined;
if (!context.membershipKnown) return { sources, unavailable, now }; if (!context.membershipKnown) return { sources, unavailable, now };
if ( if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
@@ -146,9 +146,9 @@ export function planInvestmentScreen(
); );
for (const range of [ for (const range of [
period.selected, period.selected,
...(screen.screen === 'income' ? ...(screen.screen === 'income'
[period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
[]), : []),
]) { ]) {
const bounds = dateOnlyBounds(range, now); const bounds = dateOnlyBounds(range, now);
operations(bounds.from, bounds.to); operations(bounds.from, bounds.to);
@@ -216,11 +216,11 @@ export function planInvestmentScreen(
} }
const range = const range =
screen.candleRange ?? screen.candleRange ??
(period === 'all' ? (period === 'all'
instrument.historyFrom ? ? instrument.historyFrom
{ from: instrument.historyFrom, to: now } : ? { from: instrument.historyFrom, to: now }
null : : null
{ from, to: now }); : { from, to: now });
if (range) { if (range) {
sources.push({ sources.push({
...identity, ...identity,
@@ -237,17 +237,17 @@ export function planInvestmentScreen(
} }
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
const day = const day =
screen.screen === 'stock' || screen.screen === 'bond' ? screen.screen === 'stock' || screen.screen === 'bond'
moscowDate(now) : ? moscowDate(now)
now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); : now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const from = const from =
screen.screen === 'events' && screen.from ? screen.screen === 'events' && screen.from
new Date(`${screen.from}T00:00:00Z`) : ? new Date(`${screen.from}T00:00:00Z`)
new Date(`${day}T00:00:00Z`); : new Date(`${day}T00:00:00Z`);
const to = const to =
screen.screen === 'events' && screen.to ? screen.screen === 'events' && screen.to
new Date(`${screen.to}T00:00:00Z`) : ? new Date(`${screen.to}T00:00:00Z`)
new Date(from); : new Date(from);
if ( if (
!(screen.screen === 'events' && screen.to) && !(screen.screen === 'events' && screen.to) &&
screen.screen !== 'stock' && screen.screen !== 'stock' &&
@@ -309,9 +309,9 @@ export function accountHistory(
): OperationInterval[] { ): OperationInterval[] {
return accounts.flatMap((account) => { return accounts.flatMap((account) => {
const from = new Date( const from = new Date(
account.openedOn ? account.openedOn
`${account.openedOn}T00:00:00Z` : ? `${account.openedOn}T00:00:00Z`
'1970-01-01T00:00:00Z', : '1970-01-01T00:00:00Z',
); );
return from < now ? [{ accountId: account.accountId, from, to: now }] : []; return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
}); });
@@ -324,22 +324,22 @@ export function ledgerRange(
accounts: Account[], accounts: Account[],
now: Date, now: Date,
) { ) {
const opening = accounts.every((account) => account.openedOn) ? const opening = accounts.every((account) => account.openedOn)
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
accounts.map((account) => account.openedOn ?? '').sort()[0] : ? accounts.map((account) => account.openedOn ?? '').sort()[0]
undefined; : undefined;
const from = fromValue ? const from = fromValue
new Date(fromValue) : ? new Date(fromValue)
instrumentUid ? : instrumentUid
new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); : new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
const end = toValue ? const end = toValue
new Date( ? new Date(
/^\d{4}-\d{2}-\d{2}$/.test(toValue) ? /^\d{4}-\d{2}-\d{2}$/.test(toValue)
`${toValue}T23:59:59.999Z` : ? `${toValue}T23:59:59.999Z`
toValue, : toValue,
) : )
now; : now;
return { return {
from, from,
to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
@@ -82,24 +82,24 @@ export class BrokerageOperationsSyncService {
...intervals.map((interval) => interval.to.valueOf()), ...intervals.map((interval) => interval.to.valueOf()),
); );
const targets = intervals.map((interval) => const targets = intervals.map((interval) =>
options.movingEnd && interval.to.valueOf() === movingTo ? options.movingEnd && interval.to.valueOf() === movingTo
{ ...interval, to: now } : ? { ...interval, to: now }
interval, : interval,
); );
const plan = planOperationRead(targets, coverage, now, options); const plan = planOperationRead(targets, coverage, now, options);
const complete = plan.intervals.every( const complete = plan.intervals.every(
(interval) => interval.availability === 'complete', (interval) => interval.availability === 'complete',
); );
const handle = const handle =
!options.observe && plan.load.length ? !options.observe && plan.load.length
await this.request( ? await this.request(
userId, userId,
plan.load, plan.load,
complete || Boolean(options.force), complete || Boolean(options.force),
'screen', 'screen',
connection.revision, connection.revision,
) : )
null; : null;
if (!options.observe && !options.force && options.history) { if (!options.observe && !options.force && options.history) {
const accountIds = new Set( const accountIds = new Set(
intervals.map((interval) => interval.accountId), intervals.map((interval) => interval.accountId),
@@ -267,9 +267,9 @@ export class BrokerageOperationsSyncService {
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS, Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS,
), ),
}, },
...(interval.instrumentUid ? ...(interval.instrumentUid
{ instrumentId: interval.instrumentUid } : ? { instrumentId: interval.instrumentUid }
{}), : {}),
cursor, cursor,
limit: operationsPageSize, limit: operationsPageSize,
state: 'OPERATION_STATE_EXECUTED', state: 'OPERATION_STATE_EXECUTED',
@@ -86,13 +86,13 @@ export class BrokerageOperationsService {
const filterKey = JSON.stringify([ const filterKey = JSON.stringify([
accountId, accountId,
from.toISOString(), from.toISOString(),
query.to ? query.to
new Date( ? new Date(
/^\d{4}-\d{2}-\d{2}$/.test(query.to) ? /^\d{4}-\d{2}-\d{2}$/.test(query.to)
`${query.to}T23:59:59.999Z` : ? `${query.to}T23:59:59.999Z`
query.to, : query.to,
).toISOString() : ).toISOString()
'now', : 'now',
query.instrumentUid ?? null, query.instrumentUid ?? null,
categories, categories,
limit, limit,
@@ -129,9 +129,9 @@ export class BrokerageOperationsService {
source.priority === 'screen' && source.priority === 'screen' &&
source.accountId && source.accountId &&
source.from && source.from &&
source.to ? source.to
[{ accountId: source.accountId, from: source.from, to: source.to }] : ? [{ accountId: source.accountId, from: source.from, to: source.to }]
[], : [],
); );
if (query.sync !== 'observe') { if (query.sync !== 'observe') {
const discovery = await this.membership.request( const discovery = await this.membership.request(
@@ -159,9 +159,9 @@ export class BrokerageOperationsService {
accountId === 'all' || account.accountId === accountId, accountId === 'all' || account.accountId === accountId,
) )
.flatMap((account) => .flatMap((account) =>
currentRange.from < currentRange.to ? currentRange.from < currentRange.to
[{ accountId: account.accountId, ...currentRange }] : ? [{ accountId: account.accountId, ...currentRange }]
[], : [],
); );
await this.sync.readPlan(userId, targets, { await this.sync.readPlan(userId, targets, {
movingEnd, movingEnd,
@@ -181,16 +181,16 @@ export class BrokerageOperationsService {
const saved = await this.repository.readPage(readInput); const saved = await this.repository.readPage(readInput);
const accounts = saved?.connection.accounts ?? []; const accounts = saved?.connection.accounts ?? [];
const targets = accounts.flatMap((account) => const targets = accounts.flatMap((account) =>
from < to ? from < to
[ ? [
{ {
accountId: account.accountId, accountId: account.accountId,
from, from,
to, to,
instrumentUid: query.instrumentUid, instrumentUid: query.instrumentUid,
}, },
] : ]
[], : [],
); );
const plan = planOperationRead(targets, saved?.coverage ?? [], now, { const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
movingEnd, movingEnd,
@@ -296,17 +296,17 @@ export class BrokerageOperationsService {
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
dataRevision: saved?.binding ?? 'missing', dataRevision: saved?.binding ?? 'missing',
availability: !available ? availability: !available
'missing' : ? 'missing'
complete ? : complete
'complete' : ? 'complete'
confirmed.length ? : confirmed.length
'partial' : ? 'partial'
'missing', : 'missing',
fetchedAt: lastSuccessfulAt, fetchedAt: lastSuccessfulAt,
freshForMs: plan.intervals.some((interval) => interval.stale) ? freshForMs: plan.intervals.some((interval) => interval.stale)
STALE_FRESHNESS_MS : ? STALE_FRESHNESS_MS
Number.POSITIVE_INFINITY, : Number.POSITIVE_INFINITY,
work: work ?? saved?.membershipWork ?? null, work: work ?? saved?.membershipWork ?? null,
dependencies: [membershipMeta, ...metadataDependencies], dependencies: [membershipMeta, ...metadataDependencies],
coverage: { coverage: {
@@ -320,8 +320,8 @@ export class BrokerageOperationsService {
(interval) => (interval) =>
interval.accountId === account.accountId && interval.asOf, interval.accountId === account.accountId && interval.asOf,
), ),
) ? )
new Date( ? new Date(
Math.min( Math.min(
...accounts.map((account) => ...accounts.map((account) =>
Math.max( Math.max(
@@ -334,42 +334,42 @@ export class BrokerageOperationsService {
), ),
), ),
), ),
).toISOString() : ).toISOString()
null, : null,
historyBoundary: knownBoundary ? 'known' : 'unknown', historyBoundary: knownBoundary ? 'known' : 'unknown',
historyState: saved?.work.some( historyState: saved?.work.some(
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen, (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
) ? )
'loading' : ? 'loading'
saved?.work.some( : saved?.work.some(
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen, (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
) ? )
'failed' : ? 'failed'
knownBoundary && : knownBoundary &&
history.intervals.every( history.intervals.every(
(interval) => interval.availability === 'complete', (interval) => interval.availability === 'complete',
) ? )
'complete' : ? 'complete'
saved?.coverage.length ? : saved?.coverage.length
'partial' : ? 'partial'
'missing', : 'missing',
}, },
}); });
const last = rows.at(LAST_ROW_INDEX); const last = rows.at(LAST_ROW_INDEX);
const hasNext = Boolean(saved && saved.rows.length > limit); const hasNext = Boolean(saved && saved.rows.length > limit);
return { return {
data: data:
meta.availability === 'missing' ? meta.availability === 'missing'
null : ? null
{ : {
accountId, accountId,
items: rows.map((row) => ({ items: rows.map((row) => ({
...mapBrokerageOperation( ...mapBrokerageOperation(
row.payload as TbankOperationItem, row.payload as TbankOperationItem,
row.occurredAt, row.occurredAt,
), ),
...(accountId === 'all' ? ...(accountId === 'all'
{ ? {
account: { account: {
id: row.accountId, id: row.accountId,
name: name:
@@ -377,8 +377,8 @@ export class BrokerageOperationsService {
(account) => account.accountId === row.accountId, (account) => account.accountId === row.accountId,
)?.name ?? row.accountId, )?.name ?? row.accountId,
}, },
} : }
{}), : {}),
logoName: logoName:
instruments instruments
.get(metadataKey(row.payload as TbankOperationItem)) .get(metadataKey(row.payload as TbankOperationItem))
@@ -386,16 +386,16 @@ export class BrokerageOperationsService {
})), })),
hasNext, hasNext,
nextCursor: nextCursor:
hasNext && last && saved ? hasNext && last && saved
this.encodeCursor({ ? this.encodeCursor({
binding: saved.binding, binding: saved.binding,
movingEnd, movingEnd,
to: to.toISOString(), to: to.toISOString(),
at: last.occurredAt.toISOString(), at: last.occurredAt.toISOString(),
accountId: last.accountId, accountId: last.accountId,
id: last.brokerOperationId, id: last.brokerOperationId,
}) : })
null, : null,
observedAt: now.toISOString(), observedAt: now.toISOString(),
}, },
meta, meta,
@@ -560,13 +560,13 @@ export class BrokerageOperationsService {
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
dataRevision: saved?.binding ?? 'missing', dataRevision: saved?.binding ?? 'missing',
availability: !known ? availability: !known
'missing' : ? 'missing'
complete ? : complete
'complete' : ? 'complete'
sources.length ? : sources.length
'partial' : ? 'partial'
'missing', : 'missing',
fetchedAt, fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work, work,
@@ -579,8 +579,8 @@ export class BrokerageOperationsService {
confirmed, confirmed,
).map(serialize), ).map(serialize),
asOf: asOf:
complete && targets.length ? complete && targets.length
new Date( ? new Date(
Math.min( Math.min(
...targets.map((target) => ...targets.map((target) =>
Math.max( Math.max(
@@ -595,24 +595,24 @@ export class BrokerageOperationsService {
), ),
), ),
), ),
).toISOString() : ).toISOString()
null, : null,
historyBoundary: (saved?.connection.accounts ?? []).every( historyBoundary: (saved?.connection.accounts ?? []).every(
(account) => account.openedOn, (account) => account.openedOn,
) ? )
'known' : ? 'known'
'unknown', : 'unknown',
historyState: complete ? historyState: complete
'complete' : ? 'complete'
sources.length ? : sources.length
'partial' : ? 'partial'
'missing', : 'missing',
}, },
}); });
return { return {
meta, meta,
items: complete ? items: complete
(saved?.rows ?? []) ? (saved?.rows ?? [])
.filter((row) => .filter((row) =>
plan.intervals.some( plan.intervals.some(
(interval) => (interval) =>
@@ -626,8 +626,8 @@ export class BrokerageOperationsService {
row.payload as TbankOperationItem, row.payload as TbankOperationItem,
row.occurredAt, row.occurredAt,
), ),
...(accountId === 'all' ? ...(accountId === 'all'
{ ? {
account: { account: {
id: row.accountId, id: row.accountId,
name: name:
@@ -635,10 +635,10 @@ export class BrokerageOperationsService {
(account) => account.accountId === row.accountId, (account) => account.accountId === row.accountId,
)?.name ?? row.accountId, )?.name ?? row.accountId,
}, },
} : }
{}), : {}),
})) : }))
null, : null,
}; };
}); });
} }
@@ -93,12 +93,12 @@ export function uncoveredOperationIntervals(
return [fragment]; return [fragment];
} }
return [ return [
...(fragment.from < covered.from ? ...(fragment.from < covered.from
[{ ...fragment, to: covered.from }] : ? [{ ...fragment, to: covered.from }]
[]), : []),
...(covered.to < fragment.to ? ...(covered.to < fragment.to
[{ ...fragment, from: covered.to }] : ? [{ ...fragment, from: covered.to }]
[]), : []),
]; ];
}); });
} }
@@ -176,20 +176,20 @@ export function planOperationRead(
// Completeness is bounded by the observed end, never by the new wall clock. // Completeness is bounded by the observed end, never by the new wall clock.
return { return {
...effective, ...effective,
availability: complete ? availability: complete
('complete' as const) : ? ('complete' as const)
sources.length ? : sources.length
('partial' as const) : ? ('partial' as const)
('missing' as const), : ('missing' as const),
stale, stale,
asOf: sources.length ? asOf: sources.length
new Date( ? new Date(
Math.min( Math.min(
effective.to.valueOf(), effective.to.valueOf(),
Math.max(...sources.map((row) => row.asOf.valueOf())), Math.max(...sources.map((row) => row.asOf.valueOf())),
), ),
) : )
null, : null,
}; };
}); });
return { intervals, load: operationIntervals(load) }; return { intervals, load: operationIntervals(load) };
@@ -61,14 +61,14 @@ export function mapBrokerageOperation(
logoName: null, logoName: null,
payment: money(operation.payment), payment: money(operation.payment),
quantity: quantity:
(operationCategory(operation.type) === 'trade' ? (operationCategory(operation.type) === 'trade'
operation.quantityDone : ? operation.quantityDone
operation.quantity) === undefined ? : operation.quantity) === undefined
null : ? null
String( : String(
operationCategory(operation.type) === 'trade' ? operationCategory(operation.type) === 'trade'
operation.quantityDone : ? operation.quantityDone
operation.quantity, : operation.quantity,
), ),
price: money(operation.price), price: money(operation.price),
accruedInterest: money(operation.accruedInt), accruedInterest: money(operation.accruedInt),
@@ -82,9 +82,9 @@ function text(value: string | undefined): null | string {
function money(value: TbankMoney | undefined) { function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase(); const currency = value?.currency?.trim().toUpperCase();
return currency && value ? return currency && value
{ currency, value: formatDecimal(decimal(value)) } : ? { currency, value: formatDecimal(decimal(value)) }
null; : null;
} }
export function operationTimestamp( export function operationTimestamp(
@@ -110,9 +110,9 @@ export class BrokerageOperationsRepository {
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
const oldest = accounts.map((account) => account.openedOn).sort()[0]; const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt = const openedAt =
oldest && accounts.every((account) => account.openedOn) ? oldest && accounts.every((account) => account.openedOn)
new Date(`${oldest}T00:00:00+03:00`) : ? new Date(`${oldest}T00:00:00+03:00`)
undefined; : undefined;
const periods = input.periods?.(openedAt).map((period) => { const periods = input.periods?.(openedAt).map((period) => {
const now = new Date(); const now = new Date();
const bounds = dateOnlyBounds(period, now); const bounds = dateOnlyBounds(period, now);
@@ -176,21 +176,21 @@ export class BrokerageOperationsRepository {
const rows = await tx.tbankOperation.findMany({ const rows = await tx.tbankOperation.findMany({
where: { where: {
...scope, ...scope,
...(periods ? ...(periods
{ ? {
OR: periods.map((period) => ({ OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to }, occurredAt: { gte: period.from, lt: period.to },
})), })),
} : }
{ occurredAt: { gte: input.from, lt: input.to } }), : { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid ? ...(input.instrumentUid
{ instrumentUid: input.instrumentUid } : ? { instrumentUid: input.instrumentUid }
{}), : {}),
...(input.categories.length ? ...(input.categories.length
{ category: { in: input.categories } } : ? { category: { in: input.categories } }
{}), : {}),
...(after ? ...(after
{ ? {
OR: [ OR: [
{ occurredAt: { lt: new Date(after.at) } }, { occurredAt: { lt: new Date(after.at) } },
{ {
@@ -203,8 +203,8 @@ export class BrokerageOperationsRepository {
brokerOperationId: { gt: after.id }, brokerOperationId: { gt: after.id },
}, },
], ],
} : }
{}), : {}),
}, },
orderBy: [ orderBy: [
{ occurredAt: 'desc' }, { occurredAt: 'desc' },
@@ -305,9 +305,9 @@ export class BrokerageOperationsRepository {
}, },
data: { data: {
historyFrom: new Date( historyFrom: new Date(
account.openedOn ? account.openedOn
`${account.openedOn}T00:00:00Z` : ? `${account.openedOn}T00:00:00Z`
'1970-01-01T00:00:00Z', : '1970-01-01T00:00:00Z',
), ),
historyBoundary: account.openedOn ? 'known' : 'unknown', historyBoundary: account.openedOn ? 'known' : 'unknown',
}, },
@@ -383,42 +383,42 @@ export class BrokerageOperationsRepository {
) { ) {
const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
const additions = const additions =
previous.scopeKey === scopeKey ? previous.scopeKey === scopeKey
[] : ? []
uncoveredOperationIntervals(intervals, pending); : uncoveredOperationIntervals(intervals, pending);
const work = await tx.tbankSyncWork.update({ const work = await tx.tbankSyncWork.update({
where: { id: previous.id }, where: { id: previous.id },
data: { data: {
intent: { intent: {
atomic: previousIntent?.atomic ?? publicationAtomic, atomic: previousIntent?.atomic ?? publicationAtomic,
atomicIntervals: previousIntent?.atomic ? atomicIntervals: previousIntent?.atomic
[ ? [
...new Set([ ...new Set([
...(previousIntent.atomicIntervals ?? ...(previousIntent.atomicIntervals ??
previousIntent.intervals), previousIntent.intervals),
...additions.map(intervalKey), ...additions.map(intervalKey),
]), ]),
] : ]
[], : [],
screenIntervals: [ screenIntervals: [
...new Set([ ...new Set([
...(previousIntent?.screenIntervals ?? ...(previousIntent?.screenIntervals ??
(previous.priority === operationsSyncPriority.screen ? (previous.priority === operationsSyncPriority.screen
(previousIntent?.intervals ?? []) : ? (previousIntent?.intervals ?? [])
[])), : [])),
...(priority === operationsSyncPriority.screen ? ...(priority === operationsSyncPriority.screen
[...pending, ...additions] ? [...pending, ...additions]
.filter((item) => .filter((item) =>
intervals.some((target) => intervals.some((target) =>
operationIntervalsOverlap(item, target), operationIntervalsOverlap(item, target),
), ),
) )
.map(intervalKey) : .map(intervalKey)
[]), : []),
]), ]),
], ],
intervals: (priority < previous.priority ? intervals: (priority < previous.priority
[ ? [
...pending.filter((item) => ...pending.filter((item) =>
intervals.some((target) => intervals.some((target) =>
operationIntervalsOverlap(item, target), operationIntervalsOverlap(item, target),
@@ -431,8 +431,8 @@ export class BrokerageOperationsRepository {
operationIntervalsOverlap(item, target), operationIntervalsOverlap(item, target),
), ),
), ),
] : ]
[...pending, ...additions] : [...pending, ...additions]
).map(intervalKey), ).map(intervalKey),
}, },
priority: Math.min(priority, previous.priority), priority: Math.min(priority, previous.priority),
@@ -444,9 +444,9 @@ export class BrokerageOperationsRepository {
previous && previous &&
previous.state !== 'complete' && previous.state !== 'complete' &&
previous.state !== 'superseded'; previous.state !== 'superseded';
const retained = recovering ? const retained = recovering
(previousIntent?.intervals.map(decodeInterval) ?? []) : ? (previousIntent?.intervals.map(decodeInterval) ?? [])
[]; : [];
// A bridge between two active groups must fence both producers before // A bridge between two active groups must fence both producers before
// either can publish a subset of the newly combined result. // either can publish a subset of the newly combined result.
for (const joined of touching) { for (const joined of touching) {
@@ -469,23 +469,23 @@ export class BrokerageOperationsRepository {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
} }
const targets = const targets =
recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ? recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP
previous?.scopeKey === scopeKey ? ? previous?.scopeKey === scopeKey
retained : ? retained
[...retained, ...uncoveredOperationIntervals(intervals, retained)] : : [...retained, ...uncoveredOperationIntervals(intervals, retained)]
operationIntervals([...retained, ...intervals]); : operationIntervals([...retained, ...intervals]);
const atomicTargets = [ const atomicTargets = [
...(publicationAtomic ? intervals : []), ...(publicationAtomic ? intervals : []),
...(recovering && previousIntent?.atomic ? ...(recovering && previousIntent?.atomic
(previousIntent.atomicIntervals ?? previousIntent.intervals).map( ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
decodeInterval, decodeInterval,
) : )
[]), : []),
...touching.flatMap((work) => { ...touching.flatMap((work) => {
const joined = work.intent as OperationsIntent; const joined = work.intent as OperationsIntent;
return joined.atomic ? return joined.atomic
(joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
[]; : [];
}), }),
]; ];
const intent = { const intent = {
@@ -506,9 +506,9 @@ export class BrokerageOperationsRepository {
)) || )) ||
( (
previousIntent?.screenIntervals ?? previousIntent?.screenIntervals ??
(previous?.priority === operationsSyncPriority.screen ? (previous?.priority === operationsSyncPriority.screen
(previousIntent?.intervals ?? []) : ? (previousIntent?.intervals ?? [])
[]) : [])
).some((key) => ).some((key) =>
operationIntervalsOverlap(target, decodeInterval(key)), operationIntervalsOverlap(target, decodeInterval(key)),
), ),
@@ -563,20 +563,20 @@ export class BrokerageOperationsRepository {
marker.payload as PageProgress, marker.payload as PageProgress,
]), ]),
); );
const group = intent.atomic ? const group = intent.atomic
(intent.atomicIntervals ?? intent.intervals) : ? (intent.atomicIntervals ?? intent.intervals)
intent.intervals; : intent.intervals;
const nextKey = group.find((key) => !progress.get(key)?.complete); const nextKey = group.find((key) => !progress.get(key)?.complete);
return { return {
priority: work.priority, priority: work.priority,
atomic: intent.atomic, atomic: intent.atomic,
intervals: group.map(decodeInterval), intervals: group.map(decodeInterval),
next: nextKey ? next: nextKey
{ ? {
interval: decodeInterval(nextKey), interval: decodeInterval(nextKey),
cursor: progress.get(nextKey)?.cursor || undefined, cursor: progress.get(nextKey)?.cursor || undefined,
} : }
null, : null,
}; };
} }
@@ -795,9 +795,9 @@ export class BrokerageOperationsRepository {
where: { where: {
...scope, ...scope,
occurredAt: { gte: interval.from, lt: interval.to }, occurredAt: { gte: interval.from, lt: interval.to },
...(interval.instrumentUid ? ...(interval.instrumentUid
{ instrumentUid: interval.instrumentUid } : ? { instrumentUid: interval.instrumentUid }
{}), : {}),
}, },
}); });
const incoming = new Set(rows.map((row) => row.itemKey)); const incoming = new Set(rows.map((row) => row.itemKey));
@@ -902,9 +902,9 @@ export class BrokerageOperationsRepository {
intent.screenIntervals ?? intent.screenIntervals ??
(work.priority === operationsSyncPriority.screen ? intent.intervals : []) (work.priority === operationsSyncPriority.screen ? intent.intervals : [])
).includes(key), ).includes(key),
) ? )
operationsSyncPriority.screen : ? operationsSyncPriority.screen
operationsSyncPriority.history, : operationsSyncPriority.history,
progress: { remainingIntervals: remaining.length }, progress: { remainingIntervals: remaining.length },
}, },
}); });
@@ -36,8 +36,8 @@ export class ForecastService {
...input, ...input,
portfolioProjection: input.portfolio.dataRevision, portfolioProjection: input.portfolio.dataRevision,
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
dependency.resourceKey.startsWith('portfolio/') ? dependency.resourceKey.startsWith('portfolio/')
[ ? [
{ {
accountId: decodeURIComponent( accountId: decodeURIComponent(
dependency.resourceKey.slice('portfolio/'.length), dependency.resourceKey.slice('portfolio/'.length),
@@ -46,21 +46,21 @@ export class ForecastService {
(dependency as InvestmentResourceMetaDto).dataRevision, (dependency as InvestmentResourceMetaDto).dataRevision,
), ),
}, },
] : ]
[], : [],
), ),
sources: input.sources.flatMap((source) => sources: input.sources.flatMap((source) =>
source.version ? [source.version] : [], source.version ? [source.version] : [],
), ),
timestamps: dependencies.flatMap((dependency) => timestamps: dependencies.flatMap((dependency) =>
dependency.lastSuccessfulAt ? dependency.lastSuccessfulAt
[ ? [
{ {
resourceKey: dependency.resourceKey, resourceKey: dependency.resourceKey,
at: dependency.lastSuccessfulAt, at: dependency.lastSuccessfulAt,
}, },
] : ]
[], : [],
), ),
}); });
const timestamps = stored?.sourceTimestamps as const timestamps = stored?.sourceTimestamps as
@@ -78,33 +78,33 @@ export class ForecastService {
payload && payload &&
'positionIsStale' in payload && 'positionIsStale' in payload &&
'positionAt' in payload && 'positionAt' in payload &&
typeof payload.positionAt === 'string' ? typeof payload.positionAt === 'string'
{ ? {
...payload, ...payload,
positionIsStale: tbankSourceNeedsRefresh( positionIsStale: tbankSourceNeedsRefresh(
'portfolio', 'portfolio',
new Date(payload.positionAt), new Date(payload.positionAt),
new Date(Date.now()), new Date(Date.now()),
), ),
} : }
payload; : payload;
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: input.scopeKey, resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
availability: stored ? availability: stored
'complete' : ? 'complete'
dependencies.some( : dependencies.some(
(dependency) => dependency.availability !== 'missing', (dependency) => dependency.availability !== 'missing',
) ? )
'partial' : ? 'partial'
'missing', : 'missing',
fetchedAt: first ? new Date(first) : null, fetchedAt: first ? new Date(first) : null,
freshForMs: freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true ? data && 'positionIsStale' in data && data.positionIsStale === true
STALE_FRESHNESS_MS : ? STALE_FRESHNESS_MS
Number.POSITIVE_INFINITY, : Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
dependencies, dependencies,
}), }),
@@ -186,15 +186,15 @@ export class PortfolioService {
name: account.name, name: account.name,
type: account.type, type: account.type,
openedOn: account.openedOn, openedOn: account.openedOn,
snapshot: account.portfolio ? snapshot: account.portfolio
{ ? {
...(account.portfolio.payload as unknown as PortfolioDto), ...(account.portfolio.payload as unknown as PortfolioDto),
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
isStale: isStale:
Date.now() - account.portfolio.fetchedAt.valueOf() >= Date.now() - account.portfolio.fetchedAt.valueOf() >=
portfolioFreshForMs, portfolioFreshForMs,
} : }
null, : null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION),
@@ -220,8 +220,8 @@ export class PortfolioService {
); );
const metadataDependencies = [...metadata.entries()].flatMap( const metadataDependencies = [...metadata.entries()].flatMap(
([key, source]) => ([key, source]) =>
source.complete && source.data ? source.complete && source.data
[ ? [
investmentResourceMeta({ investmentResourceMeta({
resourceKey: `instrument/metadata/${key}`, resourceKey: `instrument/metadata/${key}`,
dataRevision: source.dataRevision, dataRevision: source.dataRevision,
@@ -230,20 +230,20 @@ export class PortfolioService {
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.metadata,
work: null, work: null,
}), }),
] : ]
[], : [],
); );
const portfolios = saved.accounts.flatMap((account) => const portfolios = saved.accounts.flatMap((account) =>
account.snapshot ? account.snapshot
[ ? [
{ {
...account.snapshot, ...account.snapshot,
positions: account.snapshot.positions.map((position) => { positions: account.snapshot.positions.map((position) => {
const shared = metadata.get(metadataKey(position))?.data; const shared = metadata.get(metadataKey(position))?.data;
return { return {
...position, ...position,
...(shared ? ...(shared
{ ? {
displayName: displayName:
shared.name?.trim() || shared.name?.trim() ||
shared.ticker?.trim() || shared.ticker?.trim() ||
@@ -252,29 +252,29 @@ export class PortfolioService {
isin: shared.isin?.trim() || position.isin, isin: shared.isin?.trim() || position.isin,
logoName: logoName:
shared.brand?.logoName?.trim() || position.logoName, shared.brand?.logoName?.trim() || position.logoName,
} : }
{}), : {}),
...(accountId === 'all' ? ...(accountId === 'all'
{ account: { id: account.id, name: account.name } } : ? { account: { id: account.id, name: account.name } }
{}), : {}),
}; };
}), }),
}, },
] : ]
[], : [],
); );
const lastSuccessfulAt = const lastSuccessfulAt =
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
saved.membership.lastSuccessfulAt; saved.membership.lastSuccessfulAt;
const data = const data =
missing || !lastSuccessfulAt ? missing || !lastSuccessfulAt
null : ? null
{ : {
lastSuccessfulAt, lastSuccessfulAt,
isStale: portfolios.length ? isStale: portfolios.length
portfolios.some((portfolio) => portfolio.isStale) : ? portfolios.some((portfolio) => portfolio.isStale)
saved.membership.freshness !== 'fresh', : saved.membership.freshness !== 'fresh',
totalsByCurrency: sumMoneyByCurrency( totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
), ),
@@ -306,15 +306,15 @@ export class PortfolioService {
]), ]),
metadataDependencies.map((dependency) => dependency.dataRevision), metadataDependencies.map((dependency) => dependency.dataRevision),
]), ]),
availability: missing ? availability: missing
portfolios.length ? ? portfolios.length
'partial' : ? 'partial'
'missing' : : 'missing'
'complete', : 'complete',
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
freshForMs: portfolios.length ? freshForMs: portfolios.length
portfolioFreshForMs : ? portfolioFreshForMs
membershipFreshForMs, : membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null, work: work.id ? { ...work, id: work.id } : null,
dependencies: [ dependencies: [
saved.membership, saved.membership,
@@ -478,8 +478,8 @@ export class PortfolioService {
const absoluteReturn = const absoluteReturn =
currentPrice && currentPrice &&
averagePurchasePrice && averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency ? currentPrice.currency === averagePurchasePrice.currency
{ ? {
currency: currentPrice.currency, currency: currentPrice.currency,
value: formatDecimal( value: formatDecimal(
multiply( multiply(
@@ -490,8 +490,8 @@ export class PortfolioService {
quantity, quantity,
), ),
), ),
} : }
null; : null;
return { return {
figi, figi,
instrumentUid, instrumentUid,
@@ -523,8 +523,8 @@ export class PortfolioService {
private money(value: TbankMoney | undefined): MoneyDto | null { private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase(); const currency = value?.currency?.trim().toUpperCase();
return currency && value ? return currency && value
{ currency, value: formatDecimal(decimal(value)) } : ? { currency, value: formatDecimal(decimal(value)) }
null; : null;
} }
} }
@@ -43,9 +43,9 @@ export function portfolioStructure(
currencies.set(money.currency, group); currencies.set(money.currency, group);
}; };
for (const position of positions) { for (const position of positions) {
const type = ['BOND', 'STOCK'].includes(position.type) ? const type = ['BOND', 'STOCK'].includes(position.type)
position.type : ? position.type
'OTHER'; : 'OTHER';
if (position.currentValue) include(type, position.currentValue); if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
} }
@@ -65,8 +65,8 @@ export function portfolioStructure(
value: formatDecimal(value), value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the // Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry. // resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares ? sharePercent: canCalculateShares
formatDecimal({ ? formatDecimal({
value: value:
(value.value * (value.value *
DECIMAL_RADIX ** BigInt(total.scale - value.scale) * DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
@@ -74,8 +74,8 @@ export function portfolioStructure(
total.value / ROUNDING_DIVISOR) / total.value / ROUNDING_DIVISOR) /
total.value, total.value,
scale: SHARE_PERCENT_DECIMAL_PLACES, scale: SHARE_PERCENT_DECIMAL_PLACES,
}) : })
null, : null,
})); }));
return { return {
currency, currency,
@@ -58,9 +58,9 @@ export class ForecastRepository {
where: { userId_revision_scopeKey: key }, where: { userId_revision_scopeKey: key },
}); });
const fallback = const fallback =
previous?.membershipRevision === input.membershipRevision ? previous?.membershipRevision === input.membershipRevision
previous : ? previous
null; : null;
if (!input.candidate) return fallback; if (!input.candidate) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({ const portfolios = await tx.tbankPortfolio.findMany({
where: { where: {
@@ -100,23 +100,23 @@ export function normalizeScreen(
...(accountId ? { accountId } : {}), ...(accountId ? { accountId } : {}),
...(input.from ? { from: input.from } : {}), ...(input.from ? { from: input.from } : {}),
...(input.to ? { to: input.to } : {}), ...(input.to ? { to: input.to } : {}),
...(input.instrumentUid ? ...(input.instrumentUid
{ instrumentUid: input.instrumentUid.toLowerCase() } : ? { instrumentUid: input.instrumentUid.toLowerCase() }
{}), : {}),
...(query ? { query } : {}), ...(query ? { query } : {}),
...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}),
...(input.candleRange ? ...(input.candleRange
{ ? {
candleRange: { candleRange: {
from: new Date(input.candleRange.from).toISOString(), from: new Date(input.candleRange.from).toISOString(),
to: new Date(input.candleRange.to).toISOString(), to: new Date(input.candleRange.to).toISOString(),
}, },
} : }
{}), : {}),
...(input.categories ? ...(input.categories
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
{ categories: [...new Set(input.categories)].sort() } : ? { categories: [...new Set(input.categories)].sort() }
{}), : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}),
}; };
} }
@@ -79,9 +79,9 @@ export class TbankSyncRepository {
work, work,
children: children.map((child) => children: children.map((child) =>
progress.attempts?.[child.id] && progress.attempts?.[child.id] &&
progress.attempts[child.id] !== child.attemptId ? progress.attempts[child.id] !== child.attemptId
{ ...child, state: 'cancelled', errorCode: null } : ? { ...child, state: 'cancelled', errorCode: null }
child, : child,
), ),
progress, progress,
}; };
@@ -120,9 +120,9 @@ export class TbankSyncService {
) )
) { ) {
for (const account of scope.screen === 'stock' || for (const account of scope.screen === 'stock' ||
scope.screen === 'bond' ? scope.screen === 'bond'
current.accounts : ? current.accounts
selectedAccounts) { : selectedAccounts) {
const work = await this.portfolio.request( const work = await this.portfolio.request(
userId, userId,
revision, revision,
@@ -135,9 +135,9 @@ export class TbankSyncService {
} }
const saved = await this.portfolio.read( const saved = await this.portfolio.read(
userId, userId,
scope.screen === 'stock' || scope.screen === 'bond' ? scope.screen === 'stock' || scope.screen === 'bond'
'all' : ? 'all'
accountScope, : accountScope,
true, true,
); );
const positions = saved.data?.positions ?? []; const positions = saved.data?.positions ?? [];
@@ -145,8 +145,8 @@ export class TbankSyncService {
...new Map( ...new Map(
positions.flatMap((position) => positions.flatMap((position) =>
position.instrumentUid && position.instrumentUid &&
(position.type === 'STOCK' || position.type === 'BOND') ? (position.type === 'STOCK' || position.type === 'BOND')
[ ? [
[ [
position.instrumentUid, position.instrumentUid,
{ {
@@ -155,27 +155,27 @@ export class TbankSyncService {
historyFrom: null, historyFrom: null,
}, },
] as const, ] as const,
] : ]
[], : [],
), ),
).values(), ).values(),
]; ];
const plan = planInvestmentScreen( const plan = planInvestmentScreen(
{ {
...scope, ...scope,
candleRange: scope.candleRange ? candleRange: scope.candleRange
{ ? {
from: new Date(scope.candleRange.from), from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to), to: new Date(scope.candleRange.to),
} : }
undefined, : undefined,
}, },
{ {
accounts: selectedAccounts, accounts: selectedAccounts,
instruments: instruments:
scope.screen === 'stocks' || scope.screen === 'bonds' ? scope.screen === 'stocks' || scope.screen === 'bonds'
[] : ? []
instruments, : instruments,
membershipKnown: Boolean(current.membershipAt), membershipKnown: Boolean(current.membershipAt),
}, },
now, now,
@@ -185,18 +185,18 @@ export class TbankSyncService {
source.kind === 'operations' && source.kind === 'operations' &&
source.accountId && source.accountId &&
source.from && source.from &&
source.to ? source.to
[ ? [
{ {
accountId: source.accountId, accountId: source.accountId,
from: source.from, from: source.from,
to: source.to, to: source.to,
...(source.instrumentUid ? ...(source.instrumentUid
{ instrumentUid: source.instrumentUid } : ? { instrumentUid: source.instrumentUid }
{}), : {}),
}, },
] : ]
[], : [],
); );
if (intervals.length) { if (intervals.length) {
await add([ await add([
@@ -278,11 +278,11 @@ export class TbankSyncService {
[uid], [uid],
true, true,
[ [
type === 'share' ? type === 'share'
'dividends' : ? 'dividends'
scope.screen === 'bond' ? : scope.screen === 'bond'
'coupons' : ? 'coupons'
'bond-events', : 'bond-events',
], ],
{ {
from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
@@ -307,14 +307,14 @@ export class TbankSyncService {
uid: scope.instrumentUid, uid: scope.instrumentUid,
instrumentType: scope.screen === 'bond' ? 'bond' : 'share', instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
period: scope.candlePeriod ?? 'month', period: scope.candlePeriod ?? 'month',
...(scope.candleRange ? ...(scope.candleRange
{ ? {
range: { range: {
from: new Date(scope.candleRange.from), from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to), to: new Date(scope.candleRange.to),
}, },
} : }
{}), : {}),
}, },
now, now,
true, true,
@@ -346,9 +346,9 @@ export class TbankSyncService {
await this.access.local(userId, intent.scope.accountId); await this.access.local(userId, intent.scope.accountId);
const children = await Promise.all( const children = await Promise.all(
saved.children.map((child) => saved.children.map((child) =>
child.state === 'superseded' ? child.state === 'superseded'
this.operations.observe(userId, child.id) : ? this.operations.observe(userId, child.id)
Promise.resolve(child), : Promise.resolve(child),
), ),
); );
const running = children.some((child) => child.state === 'running'); const running = children.some((child) => child.state === 'running');
@@ -358,15 +358,15 @@ export class TbankSyncService {
null; null;
const state = const state =
saved.work.processRunId !== this.runId && saved.work.processRunId !== this.runId &&
(!saved.progress.dispatched || running) ? (!saved.progress.dispatched || running)
'cancelled' : ? 'cancelled'
!saved.progress.dispatched || running ? : !saved.progress.dispatched || running
'running' : ? 'running'
failed ? : failed
'failed' : ? 'failed'
children.some((child) => child.state === 'cancelled') ? : children.some((child) => child.state === 'cancelled')
'cancelled' : ? 'cancelled'
'succeeded'; : 'succeeded';
const resources = await this.observe(userId, intent.scope); const resources = await this.observe(userId, intent.scope);
if ( if (
(await this.access.local(userId, intent.scope.accountId)).revision !== (await this.access.local(userId, intent.scope.accountId)).revision !==
@@ -423,9 +423,9 @@ export class TbankSyncService {
typeof input.kind === 'string' typeof input.kind === 'string'
) { ) {
const range = const range =
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
(JSON.parse(input.rangeKey) as string[]) : ? (JSON.parse(input.rangeKey) as string[])
null; : null;
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
type, type,
@@ -434,9 +434,9 @@ export class TbankSyncService {
[ [
input.kind as Parameters< input.kind as Parameters<
CatalogEnrichmentService['readMany'] CatalogEnrichmentService['readMany']
>[4] extends (infer K)[] | undefined ? >[4] extends (infer K)[] | undefined
K : ? K
never, : never,
], ],
range ? { from: range[0], to: range[1] } : undefined, range ? { from: range[0], to: range[1] } : undefined,
); );
@@ -492,19 +492,19 @@ export class TbankSyncService {
); );
} }
} }
const observed = work.scopeKey.startsWith('operations:') ? const observed = work.scopeKey.startsWith('operations:')
await this.operations.observe(userId, work.id) : ? await this.operations.observe(userId, work.id)
work; : work;
const state = const state =
observed.state === 'running' ? observed.state === 'running'
observed.updatedAt < this.startedAt ? ? observed.updatedAt < this.startedAt
'cancelled' : ? 'cancelled'
'running' : : 'running'
observed.state === 'failed' ? : observed.state === 'failed'
'failed' : ? 'failed'
observed.state === 'cancelled' ? : observed.state === 'cancelled'
'cancelled' : ? 'cancelled'
'succeeded'; : 'succeeded';
if ((await this.access.local(userId)).revision !== work.revision) { if ((await this.access.local(userId)).revision !== work.revision) {
throw new NotFoundException(); throw new NotFoundException();
} }
@@ -597,27 +597,27 @@ export class TbankSyncService {
uid, uid,
instrumentType: type, instrumentType: type,
period: scope.candlePeriod ?? 'month', period: scope.candlePeriod ?? 'month',
...(scope.candleRange ? ...(scope.candleRange
{ ? {
range: { range: {
from: new Date(scope.candleRange.from), from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to), to: new Date(scope.candleRange.to),
}, },
} : }
{}), : {}),
}, },
true, true,
), ),
); );
await add( await add(
type === 'share' ? type === 'share'
this.stockIncome.readPosition(userId, uid, true) : ? this.stockIncome.readPosition(userId, uid, true)
this.bondIncome.readPosition(userId, uid, true), : this.bondIncome.readPosition(userId, uid, true),
); );
await add( await add(
type === 'share' ? type === 'share'
this.stockIncome.dividends(userId, uid, true) : ? this.stockIncome.dividends(userId, uid, true)
this.bondIncome.coupons(userId, uid, true), : this.bondIncome.coupons(userId, uid, true),
); );
} }
return resources; return resources;
@@ -154,15 +154,15 @@ export class UpcomingEventsService {
}; };
const quantity = decimalFromString(position.quantity); const quantity = decimalFromString(position.quantity);
const projected = const projected =
type === 'share' ? type === 'share'
(calendar.payload?.dividends ?? []) ? (calendar.payload?.dividends ?? [])
.filter( .filter(
(event) => (event) =>
event.dividendType?.toLowerCase() !== 'cancelled' && event.dividendType?.toLowerCase() !== 'cancelled' &&
Boolean(event.recordDate), Boolean(event.recordDate),
) )
.map((event) => dividendEvent(identity, quantity, event)) : .map((event) => dividendEvent(identity, quantity, event))
(calendar.payload?.bondEvents ?? []).map((event) => : (calendar.payload?.bondEvents ?? []).map((event) =>
bondEvent(identity, quantity, event), bondEvent(identity, quantity, event),
); );
events.push( events.push(
@@ -172,9 +172,9 @@ export class UpcomingEventsService {
) )
.map((event) => ({ .map((event) => ({
...event, ...event,
...(accountId === 'all' && position.account ? ...(accountId === 'all' && position.account
{ account: position.account } : ? { account: position.account }
{}), : {}),
})), })),
); );
} }
@@ -185,14 +185,14 @@ export class UpcomingEventsService {
a.type.localeCompare(b.type) || a.type.localeCompare(b.type) ||
a.ticker.localeCompare(b.ticker), a.ticker.localeCompare(b.ticker),
); );
const candidate: undefined | UpcomingEventsDto = ready ? const candidate: undefined | UpcomingEventsDto = ready
{ ? {
accountId, accountId,
calculatedAt: new Date().toISOString(), calculatedAt: new Date().toISOString(),
events, events,
incomeTotalsByCurrency: incomeTotals(events), incomeTotalsByCurrency: incomeTotals(events),
} : }
undefined; : undefined;
return this.forecasts.result({ return this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -224,13 +224,13 @@ export function dividendEvent(
.toISOString() .toISOString()
.slice(ISO_DATE_START, ISO_DATE_LENGTH), .slice(ISO_DATE_START, ISO_DATE_LENGTH),
dateKind: 'registry', dateKind: 'registry',
projectedAmount: event.dividendNet ? projectedAmount: event.dividendNet
projectedMoney( ? projectedMoney(
quantity, quantity,
formatDecimal(decimal(event.dividendNet)), formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency, event.dividendNet.currency,
) : )
null, : null,
}; };
} }
@@ -248,9 +248,9 @@ export function bondEvent(
date: event.date, date: event.date,
dateKind: 'event', dateKind: 'event',
projectedAmount: projectedAmount:
'value' in event ? 'value' in event
projectedMoney(quantity, event.value, event.currency) : ? projectedMoney(quantity, event.value, event.currency)
null, : null,
}; };
} }
+7 -7
View File
@@ -64,13 +64,13 @@ async function check(name, overrides = {}, signal) {
'user database must be untouched', 'user database must be untouched',
); );
const expected = const expected =
signal === 'SIGINT' ? signal === 'SIGINT'
130 : ? 130
signal === 'SIGTERM' ? : signal === 'SIGTERM'
143 : ? 143
overrides.PRISMA_SCHEMA_ENGINE_BINARY ? : overrides.PRISMA_SCHEMA_ENGINE_BINARY
1 : ? 1
0; : 0;
assert.equal(code, expected, `See ${name}.log`); assert.equal(code, expected, `See ${name}.log`);
console.log(`PASS ${name}: ${dirs.length} isolated databases`); console.log(`PASS ${name}: ${dirs.length} isolated databases`);
} }
+5 -5
View File
@@ -9,11 +9,11 @@ const available = readdirSync(__dirname)
.sort(); .sort();
const requested = process.argv.slice(2); const requested = process.argv.slice(2);
const suites = const suites =
requested.length === 0 ? requested.length === 0
['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
requested.length === 1 && requested[0] === '--all' ? : requested.length === 1 && requested[0] === '--all'
available : ? available
requested; : requested;
if (suites.some((suite) => !available.includes(suite))) { if (suites.some((suite) => !available.includes(suite))) {
throw new Error('Unknown e2e suite'); throw new Error('Unknown e2e suite');
} }
+3 -3
View File
@@ -240,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, data: { occurredAt: new Date(observedAt.valueOf() - 1000) },
}); });
const query = const query =
periodKind === 'default' ? periodKind === 'default'
'' : ? ''
`&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`;
const page = (cursor?: string, observe = false) => const page = (cursor?: string, observe = false) =>
request(app.getHttpServer()) request(app.getHttpServer())
.get( .get(
+3 -3
View File
@@ -339,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
]); ]);
await put().expect(200); await put().expect(200);
upstream.getPortfolio.mockImplementation((_token, id) => upstream.getPortfolio.mockImplementation((_token, id) =>
id === 'second' ? id === 'second'
Promise.reject(new Error('synthetic unavailable')) : ? Promise.reject(new Error('synthetic unavailable'))
Promise.resolve({ : Promise.resolve({
positions: [ positions: [
{ {
instrumentUid: uid, instrumentUid: uid,
+3 -3
View File
@@ -351,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => {
await service.read('owner', identity); await service.read('owner', identity);
await settled(); await settled();
const identities = Array.from({ length: 1000 }, (_, index) => const identities = Array.from({ length: 1000 }, (_, index) =>
index ? index
{ instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` }
identity, : identity,
); );
const saved = await service.savedMany(identities); const saved = await service.savedMany(identities);
expect(saved.size).toBe(1000); expect(saved.size).toBe(1000);
+12 -12
View File
@@ -58,9 +58,9 @@ describe('T-Bank persisted read budget', () => {
const operations = Array.from({ length: 100_000 }, (_, index) => { const operations = Array.from({ length: 100_000 }, (_, index) => {
// 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years.
const time = const time =
index < 1000 ? index < 1000
Date.UTC(2026, 7, 1) + index * 2_500_000 : ? Date.UTC(2026, 7, 1) + index * 2_500_000
Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000;
const instrument = instruments[index % instruments.length]; const instrument = instruments[index % instruments.length];
return { return {
id: `operation-${index}`, id: `operation-${index}`,
@@ -298,9 +298,9 @@ describe('T-Bank persisted read budget', () => {
uid: instrument.uid, uid: instrument.uid,
ticker: instrument.ticker, ticker: instrument.ticker,
instrumentType: instrumentType:
instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE'
'share' : ? 'share'
'bond', : 'bond',
metadata: instrument, metadata: instrument,
fetchedAt: now, fetchedAt: now,
}, },
@@ -487,13 +487,13 @@ describe('T-Bank persisted read budget', () => {
runtime: process.version, runtime: process.version,
hardware: { hardware: {
cpuQuota: cpuQuota:
process.platform === 'linux' ? process.platform === 'linux'
readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim()
null, : null,
memoryLimit: memoryLimit:
process.platform === 'linux' ? process.platform === 'linux'
readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim()
null, : null,
cpu: cpus()[0]?.model, cpu: cpus()[0]?.model,
cpus: cpus().length, cpus: cpus().length,
memoryBytes: totalmem(), memoryBytes: totalmem(),
+3 -3
View File
@@ -358,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => {
}), }),
) )
.mockImplementation(() => .mockImplementation(() =>
outcome === 'failed' ? outcome === 'failed'
Promise.reject(new Error('Synthetic outage')) : ? Promise.reject(new Error('Synthetic outage'))
Promise.resolve({ hasNext: false, items: [] }), : Promise.resolve({ hasNext: false, items: [] }),
); );
const accept = async (from: string, to: string) => { const accept = async (from: string, to: string) => {
const response = await post({ const response = await post({
+3 -3
View File
@@ -296,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;
await portfolio([position(shareUid, '7'), position(newUid, '5')]); await portfolio([position(shareUid, '7'), position(newUid, '5')]);
upstream.getDividends.mockImplementation((_token: string, uid: string) => upstream.getDividends.mockImplementation((_token: string, uid: string) =>
uid === newUid ? uid === newUid
Promise.reject(new Error('new calendar unavailable')) : ? Promise.reject(new Error('new calendar unavailable'))
Promise.resolve([]), : Promise.resolve([]),
); );
const pending = (await events(false).expect(200)) const pending = (await events(false).expect(200))
.body as UpcomingEventsResourceDto; .body as UpcomingEventsResourceDto;