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,13 +1,13 @@
import { planInvestmentScreen } from '../../investment-sync-plan';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankAccessService,
@@ -16,35 +16,36 @@ import {
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { CandleRepository } from '../infrastructure/candle.repository';
import { planInvestmentScreen } from '../../investment-sync-plan';
import {
candleGaps,
type CandleRange,
candleWindows,
utcDay,
type CandleRange,
} from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market';
type CandleInput = {
uid: string;
instrumentType: 'share' | 'bond';
period: 'month' | 'halfYear' | 'year' | 'all';
instrumentType: 'bond' | 'share';
period: 'all' | 'halfYear' | 'month' | 'year';
range?: CandleRange;
uid: string;
};
type Producer = {
input: CandleInput;
attempt: TbankSyncAttempt;
now: Date;
force: boolean;
priority: number;
input: CandleInput;
now: Date;
observers: TbankSyncAttempt[];
pass?: {
now: Date;
force: boolean;
ranges: CandleRange[];
nextWindow: number;
now: Date;
ranges: CandleRange[];
};
priority: number;
};
const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000;
@@ -61,18 +62,20 @@ function rangeFor(
const from = utcDay(now);
const history = timestamp(instrument.first_1dayCandleDate);
if (input.period === 'all') {
if (!history)
if (!history) {
throw new ServiceUnavailableException({
code:
input.instrumentType === 'share'
? 'STOCK_HISTORY_START_UNAVAILABLE'
: 'BOND_HISTORY_START_UNAVAILABLE',
input.instrumentType === 'share' ?
'STOCK_HISTORY_START_UNAVAILABLE' :
'BOND_HISTORY_START_UNAVAILABLE',
});
}
from.setTime(utcDay(new Date(history)).valueOf());
} else {
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
if (history && Date.parse(history) > from.valueOf())
if (history && Date.parse(history) > from.valueOf()) {
from.setTime(utcDay(new Date(history)).valueOf());
}
}
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
}
@@ -102,16 +105,18 @@ export class CandleService {
true,
['detail'],
);
if (detail.meta.availability === 'complete' && !detail.instrument)
if (detail.meta.availability === 'complete' && !detail.instrument) {
throw new NotFoundException();
}
const now = new Date();
const range = detail.instrument
? rangeFor(input, detail.instrument, now)
: null;
const range = detail.instrument ?
rangeFor(input, detail.instrument, now) :
null;
const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
if (!observe && (!saved || gaps.length))
if (!observe && (!saved || gaps.length)) {
await this.request(userId, revision, input, now);
}
const work = await this.repository.work(userId, revision, scope(input));
const fetchedAt =
saved?.coverage
@@ -129,14 +134,14 @@ export class CandleService {
});
return {
data:
!saved || !covered
? null
: {
!saved || !covered ?
null :
{
candles: saved.data,
currency:
input.instrumentType === 'share'
? detail.instrument?.currency?.toUpperCase() || null
: null,
input.instrumentType === 'share' ?
detail.instrument?.currency?.toUpperCase() || null :
null,
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
},
@@ -149,8 +154,8 @@ export class CandleService {
work,
coverage: {
requested: range ? [publicRange(range)] : [],
completeRanges: range
? completeCoverage.flatMap((row) => {
completeRanges: range ?
completeCoverage.flatMap((row) => {
const from = new Date(
Math.max(row.from.valueOf(), range.from.valueOf()),
);
@@ -158,22 +163,22 @@ export class CandleService {
Math.min(row.to.valueOf(), range.to.valueOf()),
);
return from < to ? [publicRange({ from, to })] : [];
})
: [],
}) :
[],
missingRanges: missing.map(publicRange),
asOf: fetchedAt?.toISOString() ?? null,
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
? 'known'
: 'unknown',
historyState: complete
? 'complete'
: work?.state === 'failed'
? 'failed'
: work?.state === 'running'
? 'loading'
: covered
? 'partial'
: 'missing',
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
'known' :
'unknown',
historyState: complete ?
'complete' :
work?.state === 'failed' ?
'failed' :
work?.state === 'running' ?
'loading' :
covered ?
'partial' :
'missing',
},
dependencies: [
detail.meta,
@@ -183,9 +188,9 @@ export class CandleService {
dataRevision: String(row.dataRevision),
availability: 'complete',
fetchedAt: row.fetchedAt,
freshForMs: row.isComplete
? Number.POSITIVE_INFINITY
: tailFreshForMs,
freshForMs: row.isComplete ?
Number.POSITIVE_INFINITY :
tailFreshForMs,
work: null,
}),
),
@@ -197,13 +202,14 @@ export class CandleService {
prefetch(
userId: string,
revision: string,
positions: { instrumentUid?: string | null; type: string }[],
positions: { instrumentUid?: null | string; type: string }[],
) {
const key = JSON.stringify([
userId,
revision,
positions
.map((position) => [position.instrumentUid, position.type])
// 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.
.sort(),
]);
const existing = this.prefetches.get(key);
@@ -218,14 +224,14 @@ export class CandleService {
private async prefetchPositions(
userId: string,
revision: string,
positions: { instrumentUid?: string | null; type: string }[],
positions: { instrumentUid?: null | string; type: string }[],
) {
const now = new Date();
const unique = new Map(
positions
.filter(
(position) =>
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
)
.map((position) => [position.instrumentUid ?? '', position]),
);
@@ -267,7 +273,7 @@ export class CandleService {
instruments: [
{
uid,
type: position.type as 'STOCK' | 'BOND',
type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null,
},
],
@@ -280,9 +286,9 @@ export class CandleService {
) {
const input = { uid, instrumentType, period: 'all' as const };
const range =
detail?.instrument && history
? rangeFor(input, detail.instrument, now)
: null;
detail?.instrument && history ?
rangeFor(input, detail.instrument, now) :
null;
if (
!range ||
candleGaps(
@@ -290,8 +296,9 @@ export class CandleService {
coverage.filter((row) => row.instrumentUid === uid),
now,
).length
)
) {
await this.request(userId, revision, input, now, false, 2);
}
}
}
}
@@ -379,6 +386,7 @@ export class CandleService {
}
const { ranges, now, force } = pass;
const index = pass.nextWindow;
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
let window = ranges[index];
if (window && !force) {
const latest = await this.repository.read(input.uid, window);
@@ -386,6 +394,7 @@ export class CandleService {
.flatMap(candleWindows)
.reverse();
ranges.splice(index, 1, ...gaps);
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
window = ranges[index];
}
if (window) {
@@ -408,8 +417,9 @@ export class CandleService {
},
];
});
if (!(await this.repository.stage(attempt, window, rows, now)))
if (!(await this.repository.stage(attempt, window, rows, now))) {
return false;
}
if (
!force &&
!(await this.repository.publish(
@@ -417,8 +427,9 @@ export class CandleService {
{ uid: input.uid, ranges: [window] },
false,
))
)
) {
return false;
}
pass.nextWindow = index + 1;
if (pass.nextWindow < ranges.length) return null;
}
@@ -441,16 +452,18 @@ export class CandleService {
}
success = result;
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(producer.attempt);
}
if (error instanceof ServiceUnavailableException) {
const response = error.getResponse();
if (
typeof response === 'object' &&
'code' in response &&
typeof response.code === 'string'
)
) {
code = response.code;
}
}
}
// Seal publication with reservations so a late forced join cannot be
@@ -458,8 +471,9 @@ export class CandleService {
const finished = this.reservations.then(async () => {
const pass = producer.pass;
if (success && pass) {
if (producer.force && (!pass.force || producer.now > pass.now))
if (producer.force && (!pass.force || producer.now > pass.now)) {
return false;
}
try {
success = await this.repository.publish(producer.attempt, {
uid: producer.input.uid,
@@ -478,8 +492,9 @@ export class CandleService {
observer,
producer.attempt,
))
)
) {
await this.repository.fail(observer, code);
}
this.active.delete(observer.attemptId);
}
this.producers.delete(scope(producer.input));
@@ -490,7 +505,7 @@ export class CandleService {
this.queue.push(producer);
continue;
}
if (success && !producer.force && producer.input.period !== 'all')
if (success && !producer.force && producer.input.period !== 'all') {
await this.request(
producer.attempt.userId,
producer.attempt.revision,
@@ -499,6 +514,7 @@ export class CandleService {
false,
2,
);
}
}
} catch {
this.logger.event('error', 'candles.persistence-failed', {
@@ -1,10 +1,10 @@
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
ConflictException,
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
TbankAccessService,
@@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
catalogQuery,
catalogScope,
type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
catalogSearchLimit,
selectCatalogInstruments,
@@ -28,10 +29,10 @@ import {
const tickerAmbiguityLimit = 2;
type Producer = {
input: CatalogQuery;
attempt: TbankSyncAttempt;
observers: TbankSyncAttempt[];
force: boolean;
input: CatalogQuery;
observers: TbankSyncAttempt[];
};
@Injectable()
export class CatalogDiscoveryService {
@@ -43,8 +44,9 @@ export class CatalogDiscoveryService {
private running = false;
private readonly completions = new Map<
string,
{ workId: string; done: Promise<void>; resolve: () => void }
{ done: Promise<void>; resolve: () => void; workId: string }
>();
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
@@ -73,14 +75,14 @@ export class CatalogDiscoveryService {
async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe);
const sources = discovery.data
? await this.enrichment.readMany(
const sources = discovery.data ?
await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
) :
[];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
@@ -108,22 +110,22 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.availability === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
discovery.data === null ?
'missing' :
sources.every((source) => source.meta.availability === 'complete') ?
'complete' :
'partial',
fetchedAt: discovery.meta.lastSuccessfulAt ?
new Date(discovery.meta.lastSuccessfulAt) :
null,
freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id
? {
work: discovery.meta.sync.id ?
{
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
} :
null,
dependencies,
}),
};
@@ -131,7 +133,7 @@ export class CatalogDiscoveryService {
async detail(
userId: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
uid: string,
observe = false,
) {
@@ -143,22 +145,24 @@ export class CatalogDiscoveryService {
['detail'],
);
const source = sources[0];
if (source.meta.availability === 'complete' && !source.instrument)
if (source.meta.availability === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(
userId,
{ instrumentType, query: instrument.ticker, exactTicker: true },
observe,
);
if (alias.data?.length === 1 && alias.data[0].uid === uid)
if (alias.data?.length === 1 && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
return {
@@ -169,17 +173,17 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
fetchedAt: source.meta.lastSuccessfulAt ?
new Date(source.meta.lastSuccessfulAt) :
null,
freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id
? {
work: source.meta.sync.id ?
{
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
} :
null,
dependencies,
}),
};
@@ -187,7 +191,7 @@ export class CatalogDiscoveryService {
async byTicker(
userId: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
ticker: string,
observe = false,
) {
@@ -197,38 +201,39 @@ export class CatalogDiscoveryService {
observe,
);
if (lookup.data?.length === 0) throw new NotFoundException();
if (lookup.data && lookup.data.length > 1)
if (lookup.data && lookup.data.length > 1) {
throw new ConflictException({
code:
instrumentType === 'share'
? 'STOCK_TICKER_AMBIGUOUS'
: 'BOND_TICKER_AMBIGUOUS',
instrumentType === 'share' ?
'STOCK_TICKER_AMBIGUOUS' :
'BOND_TICKER_AMBIGUOUS',
});
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
}
const result = lookup.data ?
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
data: result?.data ?
{ ...result.data, slug: ticker.trim().toUpperCase() } :
null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
fetchedAt: lookup.meta.lastSuccessfulAt ?
new Date(lookup.meta.lastSuccessfulAt) :
null,
freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id
? {
work: lookup.meta.sync.id ?
{
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
} :
null,
dependencies,
}),
};
@@ -245,8 +250,9 @@ export class CatalogDiscoveryService {
const saved = await this.repository.read(input);
if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
)
) {
return null;
}
const reservation = await this.repository.reserve(
{ userId, revision },
input,
@@ -291,12 +297,13 @@ export class CatalogDiscoveryService {
async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true);
if (id)
if (id) {
await Promise.all(
[...this.completions.values()]
.filter((entry) => entry.workId === id)
.map((entry) => entry.done),
);
}
const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany(
userId,
@@ -323,19 +330,19 @@ export class CatalogDiscoveryService {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
input.instrumentType === 'share' ?
await this.tbank.findShares(token, input.query) :
await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker
? found.filter(
input.exactTicker ?
found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
)
: found,
) :
found,
input.query,
input.instrumentType,
).slice(
@@ -346,17 +353,18 @@ export class CatalogDiscoveryService {
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(attempt);
else
} else {
this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE',
});
}
}
if (success) {
const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers])
if (!input.exactTicker || saved.data?.length === 1)
for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === 1) {
await this.enrichment.requestMany(
observer.userId,
observer.revision,
@@ -365,6 +373,8 @@ export class CatalogDiscoveryService {
producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
);
}
}
}
this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) {
@@ -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;
@@ -5,13 +5,13 @@ export const catalogSearchLimit = 30;
export function selectCatalogInstruments<T extends TbankShare>(
found: T[],
query: string,
instrumentType: 'share' | 'bond',
instrumentType: 'bond' | 'share',
): (T & { uid: string })[] {
const seen = new Set<string>();
const kind =
instrumentType === 'share'
? 'INSTRUMENT_TYPE_SHARE'
: 'INSTRUMENT_TYPE_BOND';
instrumentType === 'share' ?
'INSTRUMENT_TYPE_SHARE' :
'INSTRUMENT_TYPE_BOND';
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found
.filter(
@@ -21,17 +21,17 @@ export function selectCatalogInstruments<T extends TbankShare>(
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === preferredClass) -
Number(a.classCode === preferredClass) ||
Number(a.classCode === preferredClass) ||
a.uid.localeCompare(b.uid),
)
.filter((instrument) => {
const key = instrument.isin?.trim()
? `isin:${instrument.isin.trim().toUpperCase()}`
: `uid:${instrument.uid}`;
const key = instrument.isin?.trim() ?
`isin:${instrument.isin.trim().toUpperCase()}` :
`uid:${instrument.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -1,15 +1,16 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): string | null {
export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null;
return Number.isFinite(seconds) && seconds > 0 ?
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
@@ -19,7 +20,7 @@ export function moscowDay(iso: string): string {
export function previousClose(
candles: TbankCandle[],
priceAt: string,
): TbankCandle | null {
): null | TbankCandle {
return (
candles
.filter((candle) => {
@@ -32,6 +33,6 @@ export function previousClose(
);
})
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null
null
);
}
@@ -5,7 +5,6 @@ import {
formatDecimal,
multiply,
} from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto';
import {
PortfolioDto,
@@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
type: 'STOCK' | 'BOND',
type: 'BOND' | 'STOCK',
): StockPositionDto {
const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid,
@@ -40,8 +39,8 @@ export function instrumentPosition(
currentValue,
absoluteReturn,
averagePurchasePrice:
positive && totalCost
? {
positive && totalCost ?
{
currency: totalCost.currency,
value: formatDecimal(
divide(
@@ -50,15 +49,15 @@ export function instrumentPosition(
PRICE_DECIMAL_PLACES,
),
),
}
: null,
} :
null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({
...p,
returnPercent:
decimalFromString(p.quantity).value > 0n
? percent(p.absoluteReturn, cost(p))
: null,
decimalFromString(p.quantity).value > 0n ?
percent(p.absoluteReturn, cost(p)) :
null,
})),
isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt,
@@ -66,8 +65,8 @@ export function instrumentPosition(
}
function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice;
return price
? {
return price ?
{
currency: price.currency,
value: formatDecimal(
multiply(
@@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
decimalFromString(position.quantity),
),
),
}
: null;
} :
null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency;
if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
)
) {
return null;
}
return {
currency,
value: formatDecimal(
@@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
function percent(
result: MoneyDto | null,
base: MoneyDto | null,
): string | null {
): null | string {
if (
!base ||
!result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n
)
) {
return null;
}
return formatDecimal(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),