chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
@@ -1,47 +1,47 @@
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
import { CandleRepository } from '../infrastructure/candle.repository';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
decimalFromString,
divide,
formatDecimal,
} from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '../../../../common/time';
import { MoexClient } from '../../../../integrations/moex/moex.api';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { previousClose, timestamp } from './instrument-market';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankAccessService,
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond,
type TbankCandle,
TbankClient,
type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import {
CatalogDiscoveryRepository,
catalogScope,
type CatalogSource,
} from '../infrastructure/catalog-discovery.repository';
import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market';
type Producer = {
input: CatalogSource;
attempt: TbankSyncAttempt;
input: CatalogSource;
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
};
@Injectable()
@@ -54,13 +54,13 @@ export class CatalogEnrichmentService {
private running = false;
private readonly completions = new Map<
string,
{ workId: string; done: Promise<void>; resolve: () => void }
{ done: Promise<void>; resolve: () => void; workId: string }
>();
async ensureDetail(
userId: string,
revision: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uid: string,
) {
const ids = await this.requestMany(
@@ -82,6 +82,7 @@ export class CatalogEnrichmentService {
if (!rows.length) throw new Error('Instrument dependency missing');
return (rows[0].payload as CatalogPayload).instrument ?? null;
}
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
@@ -94,21 +95,21 @@ export class CatalogEnrichmentService {
async readMany(
userId: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uids: string[],
observe: boolean,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: {
from: string;
to: string;
ticker?: string;
tickers?: Record<string, string>;
to: string;
},
) {
const { revision } = await this.access.local(userId);
const inputs = sourceInputs(instrumentType, uids, kinds, range);
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
if (!observe)
if (!observe) {
await this.requestMany(
userId,
revision,
@@ -118,9 +119,10 @@ export class CatalogEnrichmentService {
kinds,
range,
);
const work = inputs.length
? await this.repository.workMany(userId, revision, inputs)
: [];
}
const work = inputs.length ?
await this.repository.workMany(userId, revision, inputs) :
[];
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
return inputs.map((input) => {
const row = rows.find(
@@ -130,16 +132,16 @@ export class CatalogEnrichmentService {
const payload = row?.payload as CatalogPayload | undefined;
return {
input,
version: row
? {
version: row ?
{
instrumentUid: row.instrumentUid,
source: row.source,
kind: row.kind,
rangeKey: row.rangeKey,
dataRevision: row.dataRevision,
fetchedAt: row.fetchedAt,
}
: null,
} :
null,
payload,
instrument: payload?.instrument ?? null,
price: payload?.price ?? null,
@@ -158,22 +160,22 @@ export class CatalogEnrichmentService {
requestMany(
userId: string,
revision: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uids: string[],
force = false,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: {
from: string;
to: string;
ticker?: string;
tickers?: Record<string, string>;
to: string;
},
) {
const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => {
const rows = inputs.length
? await this.repository.readSources(inputs)
: [];
const rows = inputs.length ?
await this.repository.readSources(inputs) :
[];
const ids: string[] = [];
for (const input of inputs) {
const row = rows.find(
@@ -187,8 +189,9 @@ export class CatalogEnrichmentService {
new Date(),
force,
)
)
) {
continue;
}
const reserved = await this.repository.reserve(
{ userId, revision },
input,
@@ -229,24 +232,24 @@ export class CatalogEnrichmentService {
async section(
userId: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uid: string,
section: 'quote' | 'company' | 'metrics',
section: 'company' | 'metrics' | 'quote',
observe = false,
) {
const kinds: CatalogSource['kind'][] =
section === 'company'
? ['detail', 'description', 'fundamentals']
: [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
section === 'company' ?
['detail', 'description', 'fundamentals'] :
[
...(instrumentType === 'bond' ?
['bond-current' as const] :
['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
...(section === 'metrics' ?
['coupons' as const, 'yield' as const, 'rating' as const] :
[]),
];
const sources = await this.readMany(
userId,
@@ -256,8 +259,9 @@ export class CatalogEnrichmentService {
kinds,
);
const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument)
if (first.meta.availability === 'complete' && !first.instrument) {
throw new NotFoundException();
}
const values = sources.reduce<CatalogPayload>(
(result, source) => ({ ...result, ...source.payload }),
{},
@@ -271,18 +275,18 @@ export class CatalogEnrichmentService {
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: !first.instrument
? 'missing'
: complete
? 'complete'
: 'partial',
fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt)
: null,
availability: !first.instrument ?
'missing' :
complete ?
'complete' :
'partial',
fetchedAt: first.meta.lastSuccessfulAt ?
new Date(first.meta.lastSuccessfulAt) :
null,
freshForMs:
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
section === 'company' ?
tbankSourceTtlMs.company :
tbankSourceTtlMs.quote,
work: null,
dependencies,
});
@@ -307,18 +311,21 @@ export class CatalogEnrichmentService {
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid)
input.instrumentType === 'share' ?
await this.tbank.getShare(token, input.uid) :
await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null };
}
if (input.kind === 'trading-status')
if (input.kind === 'trading-status') {
return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
};
}
if (input.kind === 'comparison') {
const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined },
@@ -343,16 +350,18 @@ export class CatalogEnrichmentService {
);
const rows = response.map((candle) => {
const time = timestamp(candle.time);
if (!time || !candle.close)
if (!time || !candle.close) {
throw new Error('Invalid candle response');
}
return {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
};
});
if (!(await this.candles.stage(attempt, gap, rows, now)))
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
}
if (
gaps.length &&
@@ -361,8 +370,9 @@ export class CatalogEnrichmentService {
{ uid: input.uid, ranges: gaps },
false,
))
)
) {
throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map(
(row) => {
const value = divide(
@@ -386,48 +396,51 @@ export class CatalogEnrichmentService {
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
...(input.instrumentType === 'bond' ?
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
[]),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean);
if (
!instrument &&
!['dividends', 'coupons', 'bond-events'].includes(input.kind)
)
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
) {
throw new Error('Instrument dependency missing');
if (input.kind === 'description')
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
description: instrument?.assetUid ?
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
null,
};
if (input.kind === 'fundamentals')
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
fundamentals: instrument?.assetUid ?
await this.tbank.getFundamentals(token, instrument?.assetUid) :
null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
const [from, to] = input.rangeKey?.startsWith('[') ?
(JSON.parse(input.rangeKey) as [string, string]) :
[
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons'
? {
return input.kind === 'coupons' ?
{
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
}
: {
} :
{
dividends: await this.tbank.getDividends(
token,
input.uid,
@@ -441,23 +454,25 @@ export class CatalogEnrichmentService {
if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) };
}
if (input.kind === 'yield')
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument?.ticker && instrument.isin && instrument.classCode ?
await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
)
: null,
) :
null,
};
if (input.kind === 'rating')
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
creditRating: instrument?.isin ?
await this.ratings.rating(instrument.isin) :
null,
};
}
throw new Error('Unsupported catalog source');
}
@@ -473,6 +488,7 @@ export class CatalogEnrichmentService {
let i = 0;
i < this.queue.length && batch.length < catalogSearchLimit;
) {
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
const next = this.queue[i];
if (
next.input.kind === 'last-price' &&
@@ -490,38 +506,40 @@ export class CatalogEnrichmentService {
try {
const token = await this.access.tokenForAttempt(producer.attempt);
if (!token) failed = true;
else if (producer.input.kind === 'last-price')
else if (producer.input.kind === 'last-price') {
prices = await this.tbank.getLastPrices(
token,
batch.map((item) => item.input.uid),
);
else
} else {
payload = await this.load(producer.input, token, producer.attempt);
}
} catch (error) {
failed = true;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(producer.attempt);
else
} else {
this.logger.event('warn', 'catalog-enrichment.failed', {
code: 'TBANK_UNAVAILABLE',
});
}
}
for (const item of batch) {
const price =
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
null;
const value =
item.input.kind === 'last-price'
? {
price: price
? {
item.input.kind === 'last-price' ?
{
price: price ?
{
instrumentUid: item.input.uid,
price: price.price,
time: price.time,
}
: null,
}
: payload;
} :
null,
} :
payload;
// Keep accepting observers while publication awaits SQLite.
item.observers.unshift({ attempt: item.attempt, input: item.input });
for (const observer of item.observers) {
@@ -558,14 +576,14 @@ export class CatalogEnrichmentService {
}
}
function sourceInputs(
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uids: string[],
kinds: CatalogSource['kind'][],
range?: {
from: string;
to: string;
ticker?: string;
tickers?: Record<string, string>;
to: string;
},
): CatalogSource[] {
return kinds.flatMap((kind) =>
@@ -573,20 +591,22 @@ function sourceInputs(
instrumentType,
uid,
kind,
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
? { ticker: range?.tickers?.[uid] || range?.ticker }
: {}),
...(['coupons', 'dividends'].includes(kind)
? {
rangeKey: range
? JSON.stringify([range.from, range.to])
: String(new Date().getUTCFullYear()),
}
: {}),
// 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) ?
// 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 } :
{}),
...(['coupons', 'dividends'].includes(kind) ?
{
rangeKey: range ?
JSON.stringify([range.from, range.to]) :
String(new Date().getUTCFullYear()),
} :
{}),
})),
);
}
function publicInstrument(value: TbankShare | TbankBond) {
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
@@ -625,38 +645,39 @@ const previousCloseLookbackMs =
const payoutHistoryYears = 5;
const payoutFutureYears = 2;
export type CatalogPayload = {
instrument?: TbankShare | TbankBond | null;
price?: TbankLastPrice | null;
candle?: TbankCandle | null;
tradingStatus?: string | null;
description?: string | null;
fundamentals?: TbankFundamentals | null;
coupons?: TbankCoupon[];
dividends?: TbankDividend[];
bondEvents?: MoexBondEvent[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
};
function sourcePolicy(kind: CatalogSource['kind']) {
if (
[
'last-price',
'bond-current',
'trading-status',
'comparison',
'last-price',
'trading-status',
'yield',
].includes(kind)
)
) {
return 'quote';
}
if (kind === 'description' || kind === 'fundamentals') return 'company';
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata';
}
function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' &&
(input.kind === 'detail' || input.kind === 'bond-current')
? `bond-metadata:${input.uid}`
: catalogScope(input);
(input.kind === 'detail' || input.kind === 'bond-current') ?
`bond-metadata:${input.uid}` :
catalogScope(input);
}
const quotationScale = 9;