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')),
@@ -1,8 +1,10 @@
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { Injectable } from '@nestjs/common';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market';
import {
BondDto,
@@ -32,23 +34,24 @@ export class BondCatalogService {
data:
result.data?.map((item) => {
const short = toBond(item);
const detail = item.detail
? toBond({ ...item.detail, uid: item.uid })
: short;
const detail = item.detail ?
toBond({ ...item.detail, uid: item.uid }) :
short;
const last = item.price;
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price
? formatDecimal(decimal(last.price))
: null,
pricePercent: last?.price ?
formatDecimal(decimal(last.price)) :
null,
priceAt: timestamp(last?.time),
};
}) ?? null,
};
}
async get(
userId: string,
uid: string,
@@ -57,11 +60,12 @@ export class BondCatalogService {
const result = await this.discovery.detail(userId, 'bond', uid, observe);
return {
...result,
data: result.data
? { ...toBond(result.data), slug: result.data.slug }
: null,
data: result.data ?
{ ...toBond(result.data), slug: result.data.slug } :
null,
};
}
async getByTicker(
userId: string,
ticker: string,
@@ -75,9 +79,9 @@ export class BondCatalogService {
);
return {
...result,
data: result.data
? { ...toBond(result.data), slug: result.data.slug }
: null,
data: result.data ?
{ ...toBond(result.data), slug: result.data.slug } :
null,
};
}
}
@@ -1,18 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
divide,
} from '../../../../../common/decimal';
import {
TbankAccessService,
type TbankCoupon,
type TbankBond,
type TbankCoupon,
} from '../../../../../integrations/tbank/tbank.api';
import {
PortfolioService,
ForecastService,
PortfolioService,
} from '../../../portfolio/portfolio.api';
import {
CatalogEnrichmentService,
@@ -20,14 +21,14 @@ import {
} from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import { money } from '../infrastructure/bond-money.mapper';
import {
BondCouponsDto,
BondCouponsResourceDto,
BondPositionDto,
BondMetricsDto,
BondMetricsResourceDto,
BondPositionDto,
} from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper';
import { bondQuote } from './bond-market.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@@ -40,6 +41,7 @@ export class BondIncomeService {
private readonly access: TbankAccessService,
private readonly forecasts: ForecastService,
) {}
async metrics(
userId: string,
uid: string,
@@ -53,9 +55,9 @@ export class BondIncomeService {
observe,
);
return {
data: result.values.instrument
? bondMetrics(result.values, result.unavailable, result.observedAt)
: null,
data: result.values.instrument ?
bondMetrics(result.values, result.unavailable, result.observedAt) :
null,
meta: result.meta,
};
}
@@ -67,6 +69,7 @@ export class BondIncomeService {
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
};
}
async coupons(
userId: string,
uid: string,
@@ -85,21 +88,22 @@ export class BondIncomeService {
['detail', 'coupons'],
{ from, to },
);
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
throw new NotFoundException();
}
const events = sources[1].payload?.coupons;
const position = portfolio.data
? instrumentPosition(portfolio.data, uid, 'BOND')
: null;
const projected = events
? bondCoupons(
const position = portfolio.data ?
instrumentPosition(portfolio.data, uid, 'BOND') :
null;
const projected = events ?
bondCoupons(
events,
position,
{ from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data,
)
: undefined;
) :
undefined;
const result = await this.forecasts.result({
userId,
revision: connection.revision,
@@ -110,9 +114,9 @@ export class BondIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
return result.data || !projected
? result
: {
return result.data || !projected ?
result :
{
data: bondCoupons(
events ?? [],
null,
@@ -135,19 +139,19 @@ export function bondCoupons(
const { from, to, today } = window;
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
decimalFromString(position.quantity) :
null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
const timing = !paymentDate ?
('undated' as const) :
paymentDate >= today ?
('upcoming' as const) :
('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
@@ -161,14 +165,14 @@ export function bondCoupons(
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today
? {
recordDate >= today ?
{
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
}
: null,
} :
null,
};
})
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
@@ -214,8 +218,8 @@ export function bondMetrics(
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n
? formatDecimal(
decimalFromString(clean.value).value > 0n ?
formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
@@ -227,14 +231,14 @@ export function bondMetrics(
),
COUPON_YIELD_DECIMAL_PLACES,
),
)
: null;
) :
null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
? next.couponType
: instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING'
: null;
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
next.couponType :
instrument.floatingCouponFlag === true ?
'COUPON_TYPE_FLOATING' :
null;
return {
creditRating,
coupon,
@@ -1,10 +1,7 @@
import { CandleService } from '../../application/candle.service';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
add,
decimalFromString,
@@ -14,15 +11,19 @@ import {
subtract,
} from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { timestamp } from '../../application/instrument-market';
import { money } from '../infrastructure/bond-money.mapper';
import {
BondCandlesQueryDto,
BondQuoteDto,
BondQuoteResourceDto,
} from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper';
const PRICE_DECIMAL_PLACES = 9;
@Injectable()
export class BondMarketService {
@@ -30,6 +31,7 @@ export class BondMarketService {
private readonly history: CandleService,
private readonly sources: CatalogEnrichmentService,
) {}
async quote(
userId: string,
uid: string,
@@ -43,12 +45,13 @@ export class BondMarketService {
observe,
);
return {
data: result.values.instrument
? bondQuote(result.values, result.unavailable, result.observedAt)
: null,
data: result.values.instrument ?
bondQuote(result.values, result.unavailable, result.observedAt) :
null,
meta: result.meta,
};
}
candles(
userId: string,
uid: string,
@@ -77,8 +80,8 @@ export function bondQuote(
const cleanPrice =
pricePercent !== null &&
nominal &&
decimalFromString(nominal.value).value > 0n
? {
decimalFromString(nominal.value).value > 0n ?
{
currency: nominal.currency,
value: formatDecimal(
divide(
@@ -90,11 +93,11 @@ export function bondQuote(
PRICE_DECIMAL_PLACES,
),
),
}
: null;
} :
null;
const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
cleanPrice && aci && aci.currency === cleanPrice.currency ?
{
currency: cleanPrice.currency,
value: formatDecimal(
add(
@@ -102,8 +105,8 @@ export function bondQuote(
decimalFromString(aci.value),
),
),
}
: null;
} :
null;
return {
pricePercent,
cleanPrice,
@@ -112,9 +115,9 @@ export function bondQuote(
priceAt,
tradingStatus,
changePercentagePoints:
last?.price && previous?.close
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
: null,
last?.price && previous?.close ?
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
unavailable,
observedAt,
@@ -1,7 +1,8 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service';
import { instrumentPosition } from '../../application/instrument-position';
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
@@ -122,9 +123,9 @@ describe('bond financial projections', () => {
{
couponDate: time('2026-10-10'),
payOneBond:
scenario === 'unknown'
? undefined
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
scenario === 'unknown' ?
undefined :
money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90,
},
],
@@ -1,10 +1,10 @@
import { formatDecimal } from '../../../../../common/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
return value && currency ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
}
@@ -1,4 +1,5 @@
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import type { Request } from 'express';
import {
Controller,
Get,
@@ -9,24 +10,25 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service';
import {
BondCandlesResourceDto,
BondCandlesQueryDto,
BondCandlesResourceDto,
BondCouponsResourceDto,
BondResourceDto,
BondMetricsResourceDto,
BondPositionResourceDto,
BondQuoteResourceDto,
BondResourceDto,
BondSearchQueryDto,
BondSearchResourceDto,
BondTickerParamsDto,
} from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds')
@ApiProblemResponses()
@@ -39,6 +41,7 @@ export class BondController {
private readonly market: BondMarketService,
private readonly income: BondIncomeService,
) {}
@Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsResourceDto })
metrics(
@@ -48,6 +51,7 @@ export class BondController {
) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
}
@Get()
@ApiOkResponse({ type: BondSearchResourceDto })
search(
@@ -60,6 +64,7 @@ export class BondController {
query.sync === 'observe',
);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: BondResourceDto })
getByTicker(
@@ -73,6 +78,7 @@ export class BondController {
query.sync === 'observe',
);
}
@Get(':uid')
@ApiOkResponse({ type: BondResourceDto })
get(
@@ -82,6 +88,7 @@ export class BondController {
) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/quote')
@ApiOkResponse({ type: BondQuoteResourceDto })
quote(
@@ -91,6 +98,7 @@ export class BondController {
) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesResourceDto })
candles(
@@ -105,6 +113,7 @@ export class BondController {
query.sync === 'observe',
);
}
@Get(':uid/position')
@ApiOkResponse({ type: BondPositionResourceDto })
position(
@@ -118,6 +127,7 @@ export class BondController {
query.sync === 'observe',
);
}
@Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsResourceDto })
coupons(
@@ -1,7 +1,7 @@
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../../../common/money.dto';
import { MoneyDto } from '../../../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import {
StockCandlesDto,
StockCandlesQueryDto,
@@ -20,44 +20,61 @@ export class BondDto {
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
declare slug: string;
@ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) declare isin: null | string;
@ApiProperty({ type: String, nullable: true }) declare classCode:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
null | string;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: null | string;
@ApiProperty({ type: String, nullable: true }) declare logoName:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare sector: null | string;
@ApiProperty({ type: Number, nullable: true }) declare lot: null | number;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
declare historyFrom: null | string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare initialNominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare maturityDate:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare callDate:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare bondType:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare riskLevel:
string | null;
null | string;
@ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear:
number | null;
null | number;
@ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon:
boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare perpetual:
boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare amortization:
boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare subordinated:
boolean | null;
}
@@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto {
nullable: true,
description: 'Clean price in percent of nominal, not money.',
})
declare pricePercent: string | null;
declare pricePercent: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
declare priceAt: null | string;
}
export class BondQuoteDto {
@ApiProperty({ type: String, nullable: true }) declare pricePercent:
string | null;
null | string;
@ApiProperty({
type: MoneyDto,
nullable: true,
@@ -82,6 +101,7 @@ export class BondQuoteDto {
'Indicative clean value at the current nominal; no FX conversion.',
})
declare cleanPrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({
type: MoneyDto,
@@ -90,41 +110,53 @@ export class BondQuoteDto {
'Indicative clean value plus current accrued interest, excluding fees.',
})
declare dirtyPrice: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
declare changePercentagePoints: string | null;
declare changePercentagePoints: null | string;
@ApiProperty({ type: String, nullable: true }) declare previousCloseDate:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
declare priceAt: null | string;
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class BondCouponDto {
@ApiProperty({ type: String, nullable: true }) declare number: string | null;
@ApiProperty({ type: String, nullable: true }) declare number: null | string;
@ApiProperty({ type: String, nullable: true }) declare paymentDate:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare recordDate:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare couponType:
string | null;
null | string;
@ApiProperty({ type: Number, nullable: true }) declare periodDays:
number | null;
null | number;
@ApiProperty({ type: MoneyDto, nullable: true })
declare perBond: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated';
'past' | 'undated' | 'upcoming';
}
export class BondCouponsDto {
@ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[];
@ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[];
@ApiProperty() declare from: string;
@ApiProperty() declare to: string;
@ApiProperty() declare observedAt: string;
@ApiProperty({ type: String, nullable: true }) declare positionAt:
string | null;
null | string;
@ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean;
}
@@ -132,6 +164,7 @@ export class BondCouponsDto {
export class BondYieldDto {
@ApiProperty({ description: 'MOEX effective annual yield in percent.' })
declare value: string;
@ApiProperty() declare pricePercent: string;
@ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({
@@ -139,14 +172,16 @@ export class BondYieldDto {
nullable: true,
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
})
declare dateType: string | null;
declare dateType: null | string;
@ApiProperty({ format: 'date-time' }) declare tradeAt: string;
}
export class BondCreditRatingDto {
@ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR';
@ApiProperty({ type: String, nullable: true }) declare value: string | null;
@ApiProperty({ type: String, nullable: true }) declare value: null | string;
@ApiProperty({ enum: ['active', 'withdrawn'] }) declare status:
'active' | 'withdrawn';
@ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ format: 'uri' }) declare sourceUrl: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
@@ -154,39 +189,50 @@ export class BondCreditRatingDto {
export class BondMetricsDto {
@ApiProperty({ type: BondCreditRatingDto, nullable: true })
declare creditRating: BondCreditRatingDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare coupon: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare couponDate: string | null;
declare couponDate: null | string;
@ApiProperty({ type: String, nullable: true }) declare couponType:
string | null;
null | string;
@ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays:
number | null;
null | number;
@ApiProperty({
type: String,
nullable: true,
description:
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.',
})
declare couponYieldPercent: string | null;
declare couponYieldPercent: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare couponYieldPriceAt: string | null;
declare couponYieldPriceAt: null | string;
@ApiProperty({ type: BondYieldDto, nullable: true })
declare effectiveYield: BondYieldDto | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class BondPositionResourceDto {
@ApiProperty({ type: BondPositionDto, nullable: true })
declare data: BondPositionDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class BondSearchResourceDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
declare data: BondSearchResultDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -200,6 +246,7 @@ export class BondResourceDto {
export class BondQuoteResourceDto {
@ApiProperty({ type: BondQuoteDto, nullable: true })
declare data: BondQuoteDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -207,6 +254,7 @@ export class BondQuoteResourceDto {
export class BondMetricsResourceDto {
@ApiProperty({ type: BondMetricsDto, nullable: true })
declare data: BondMetricsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -214,6 +262,7 @@ export class BondMetricsResourceDto {
export class BondCandlesResourceDto {
@ApiProperty({ type: BondCandlesDto, nullable: true })
declare data: BondCandlesDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -221,6 +270,7 @@ export class BondCandlesResourceDto {
export class BondCouponsResourceDto {
@ApiProperty({ type: BondCouponsDto, nullable: true })
declare data: BondCouponsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => {
expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual(
365 * 86400000,
);
if (index) expect(window.from).toEqual(windows[index - 1].to);
expect(window.from).toEqual(index ? windows[index - 1].to : range.from);
});
});
});
@@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time';
export type CandleRange = { from: Date; to: Date };
export type CandleCoverage = CandleRange & {
isComplete: boolean;
fetchedAt: Date;
isComplete: boolean;
};
export type SavedCandle = {
time: string;
close: string;
isComplete: boolean;
time: string;
};
const windowDays = 365;
const tailFreshForMs = 60_000;
@@ -30,14 +30,15 @@ export function candleGaps(
!covered.isComplete &&
(now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs ||
utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf())
)
) {
continue;
}
gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [
...(covered.from > gap.from
? [{ from: gap.from, to: covered.from }]
: []),
...(covered.from > gap.from ?
[{ from: gap.from, to: covered.from }] :
[]),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
];
});
@@ -73,11 +74,14 @@ export function confirmedCandleCoverage(
for (const boundary of [
midnight,
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
])
if (boundary > range.from.valueOf() && boundary < range.to.valueOf())
]) {
if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) {
boundaries.add(boundary);
}
}
const sorted = [...boundaries].sort((a, b) => a - b);
return sorted.slice(1).map((to, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array.
const from = sorted[index];
return {
from: new Date(from),
@@ -1,24 +1,25 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
confirmedCandleCoverage,
type CandleRange,
confirmedCandleCoverage,
type SavedCandle,
} from '../domain/candle-coverage';
const provider = 'tbank';
const interval = 'day';
type CandleIntent = {
instrumentType?: 'share' | 'bond';
uid: string;
ranges: CandleRange[];
period?: string;
now?: string;
force?: boolean;
instrumentType?: 'bond' | 'share';
now?: string;
period?: string;
ranges: CandleRange[];
uid: string;
};
@Injectable()
export class CandleRepository {
@@ -57,7 +58,7 @@ export class CandleRepository {
}
reserve(
owner: { userId: string; revision: string },
owner: { revision: string; userId: string },
scopeKey: string,
intent: CandleIntent,
runId: string,
@@ -88,29 +89,31 @@ export class CandleRepository {
(!intent.force ||
(previousIntent.force === true &&
(!intent.now || intent.now <= previousNow)))
)
) {
return { work: previous, launch: false };
}
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
priority: nextPriority,
intent: {
...previousIntent,
...(intent.force
? {
...(intent.force ?
{
force: true,
...(intent.now && intent.now > previousNow
? { now: intent.now }
: {}),
}
: {}),
...(intent.now && intent.now > previousNow ?
{ now: intent.now } :
{}),
} :
{}),
},
},
});
return { work, launch: false };
}
if (previous)
if (previous) {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
@@ -142,12 +145,15 @@ export class CandleRepository {
fetchedAt: Date,
) {
// Reject malformed financial input instead of publishing a false empty range.
for (const candle of candles)
for (const candle of candles) {
if (
!Number.isFinite(Date.parse(candle.time)) ||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
!/^-?\d+(?:\.\d+)?$/.test(candle.close)
)
) {
throw new Error('Invalid candle');
}
}
const key = `${range.from.toISOString()}/${range.to.toISOString()}`;
const rows = [
...new Map(
@@ -155,7 +161,7 @@ export class CandleRepository {
.filter(
(candle) =>
new Date(candle.time) >= range.from &&
new Date(candle.time) < range.to,
new Date(candle.time) < range.to,
)
.map((candle) => [candle.time, candle]),
).values(),
@@ -222,8 +228,9 @@ export class CandleRepository {
`${range.from.toISOString()}/${range.to.toISOString()}`,
),
)
)
) {
throw new Error('Incomplete candle reconciliation');
}
const identity = { provider, instrumentUid: intent.uid, interval };
for (const range of intent.ranges) {
const row = staged.find(
@@ -254,21 +261,22 @@ export class CandleRepository {
// Preserve coverage outside the requested reconciliation boundaries.
for (const previous of old) {
for (const part of [
...(previous.from < range.from
? [{ from: previous.from, to: range.from }]
: []),
...(previous.to > range.to
? [{ from: range.to, to: previous.to }]
: []),
])
...(previous.from < range.from ?
[{ from: previous.from, to: range.from }] :
[]),
...(previous.to > range.to ?
[{ from: range.to, to: previous.to }] :
[]),
]) {
await tx.tbankCandleCoverage.create({
data: { ...previous, ...part },
});
}
}
await tx.tbankCandle.deleteMany({
where: { ...identity, time: { gte: range.from, lt: range.to } },
});
for (const candle of payload.candles)
for (const candle of payload.candles) {
await tx.tbankCandle.create({
data: {
...identity,
@@ -279,14 +287,16 @@ export class CandleRepository {
fetchedAt,
},
});
}
for (const covered of confirmedCandleCoverage(
range,
payload.candles,
fetchedAt,
))
)) {
await tx.tbankCandleCoverage.create({
data: { ...identity, ...covered },
});
}
}
await tx.tbankSyncStage.deleteMany({
where: { workId: attempt.id, attemptId: attempt.attemptId },
@@ -1,40 +1,42 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
TbankBond,
TbankShare,
TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
export type CatalogQuery = {
instrumentType: 'share' | 'bond';
query: string;
exactTicker?: boolean;
instrumentType: 'bond' | 'share';
query: string;
};
export type CatalogSource = {
instrumentType: 'share' | 'bond';
uid: string;
instrumentType: 'bond' | 'share';
kind:
| 'detail'
| 'last-price'
| 'bond-current'
| 'description'
| 'fundamentals'
| 'trading-status'
| 'comparison'
| 'coupons'
| 'yield'
| 'rating'
| 'dividends'
| 'bond-events';
| 'bond-current' |
'bond-events' |
'comparison' |
'coupons' |
'description' |
'detail' |
'dividends' |
'fundamentals' |
'last-price' |
'rating' |
'trading-status' |
'yield';
rangeKey?: string;
ticker?: string;
uid: string;
};
export type CatalogWork = CatalogQuery | CatalogSource;
export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string };
export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string };
const provider = 'tbank';
export function catalogQuery(input: CatalogQuery): CatalogQuery {
return {
@@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery {
};
}
export function catalogScope(input: CatalogWork) {
if ('uid' in input)
if ('uid' in input) {
return `catalog-source:${JSON.stringify([
input.kind,
input.kind === 'detail' ? input.instrumentType : null,
input.rangeKey ?? '',
input.uid,
])}`;
}
return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`;
}
@@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository {
read(input: CatalogQuery) {
return this.prisma.$transaction(async (tx) => {
const alias = input.exactTicker
? await tx.tbankInstrumentLookup.findUnique({
const alias = input.exactTicker ?
await tx.tbankInstrumentLookup.findUnique({
where: {
provider_identifierType_identifier: {
provider,
@@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository {
identifier: input.query,
},
},
})
: null;
const discovery = input.exactTicker
? alias
? {
}) :
null;
const discovery = input.exactTicker ?
alias ?
{
instrumentUids: alias.matchedUids,
fetchedAt: alias.fetchedAt,
dataRevision: alias.dataRevision,
}
: null
: await tx.tbankCatalogDiscovery.findUnique({
} :
null :
await tx.tbankCatalogDiscovery.findUnique({
where: {
provider_instrumentType_query: {
provider,
@@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository {
});
const byUid = new Map(rows.map((row) => [row.instrumentUid, row]));
// A damaged/missing dependency is unknown, never a confirmed empty search.
if (uids.some((uid) => !byUid.has(uid)))
if (uids.some((uid) => !byUid.has(uid))) {
return {
data: null,
fetchedAt: null,
dataRevision: String(discovery.dataRevision),
};
}
return {
data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument),
fetchedAt: discovery.fetchedAt,
@@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository {
kind: input.kind,
source: sourceProvider(input),
rangeKey:
input.kind === 'detail'
? input.instrumentType
: (input.rangeKey ?? ''),
input.kind === 'detail' ?
input.instrumentType :
(input.rangeKey ?? ''),
})),
},
});
@@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository {
},
});
const instrument = (
value as { instrument?: TbankShare | TbankBond | null }
value as { instrument?: null | TbankBond | TbankShare }
).instrument;
if (
(input.kind === 'detail' || input.kind === 'bond-current') &&
@@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previousAlias?.matchedUids,
matchedUids,
)
? 0
: 1,
) ?
0 :
1,
},
},
});
@@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository {
source: sourceProvider(input),
kind: input.kind,
rangeKey:
input.kind === 'detail'
? input.instrumentType
: (input.rangeKey ?? ''),
input.kind === 'detail' ?
input.instrumentType :
(input.rangeKey ?? ''),
};
const previous = await tx.tbankInstrumentSource.findUnique({
where: { provider_instrumentUid_source_kind_rangeKey: key },
@@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository {
}
reserve(
owner: { userId: string; revision: string },
owner: { revision: string; userId: string },
input: CatalogWork,
runId: string,
active: ReadonlySet<string>,
@@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
@@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository {
instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency,
brand: instrument.brand
? { logoName: instrument.brand.logoName }
: undefined,
brand: instrument.brand ?
{ logoName: instrument.brand.logoName } :
undefined,
}),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({
@@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previous?.matchedUids,
instrumentUids,
)
? 0
: 1,
) ?
0 :
1,
},
},
});
@@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previous?.instrumentUids,
instrumentUids,
)
? 0
: 1,
) ?
0 :
1,
},
},
});
@@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository {
}
function sourceProvider(input: CatalogSource) {
return input.kind === 'yield' || input.kind === 'bond-events'
? 'moex'
: input.kind === 'rating'
? 'ncr'
: 'tbank';
return input.kind === 'yield' || input.kind === 'bond-events' ?
'moex' :
input.kind === 'rating' ?
'ncr' :
'tbank';
}
@@ -1,2 +1,2 @@
export { StockIncomeService } from './stocks/application/stock-income.service';
export { BondIncomeService } from './bonds/application/bond-income.service';
export { StockIncomeService } from './stocks/application/stock-income.service';
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { CandleService } from './application/candle.service';
import { CandleRepository } from './infrastructure/candle.repository';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
import { CandleRepository } from './infrastructure/candle.repository';
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
@Module({
@@ -1,5 +1,5 @@
export { CandleService } from './application/candle.service';
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
export type { CatalogPayload } from './application/catalog-enrichment.service';
export { CatalogDiscoveryService } from './application/catalog-discovery.service';
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
export type { CatalogPayload } from './application/catalog-enrichment.service';
@@ -1,17 +1,18 @@
import { InstrumentSourcesModule } from './instrument-sources.module';
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
import { BondIncomeService } from './bonds/application/bond-income.service';
import { BondMarketService } from './bonds/application/bond-market.service';
import { BondController } from './bonds/presentation/bond.controller';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
import { InstrumentSourcesModule } from './instrument-sources.module';
import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service';
import { StockController } from './stocks/presentation/stock.controller';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
@Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import {
StockCompanyResourceDto,
@@ -21,8 +23,8 @@ export class GetStockCompanyUseCase {
observe,
);
return {
data: result.values.instrument
? {
data: result.values.instrument ?
{
description: result.values.description ?? null,
metrics: metrics(
result.values.fundamentals ?? null,
@@ -30,14 +32,14 @@ export class GetStockCompanyUseCase {
),
unavailable: result.unavailable,
observedAt: result.observedAt,
}
: null,
} :
null,
meta: result.meta,
};
}
}
export function metrics(
values: TbankFundamentals | null,
values: null | TbankFundamentals,
sector?: string,
): StockMetricDto[] {
if (!values) return [];
@@ -49,30 +51,31 @@ export function metrics(
][] = [
['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'],
...(bank
? ([
...(bank ?
([
['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null],
] as typeof selection)
: ([
] as typeof selection) :
([
['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'],
...(!bank
? ([
...(!bank ?
([
['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null],
] as typeof selection)
: []),
] as typeof selection) :
[]),
];
return selection.flatMap(([key, unit, period]) => {
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
const value = values[key];
const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || currency)
? [
(unit !== 'money' || currency) ?
[
{
key,
value,
@@ -80,7 +83,7 @@ export function metrics(
period,
currency: unit === 'money' ? currency : null,
},
]
: [];
] :
[];
});
}
@@ -1,8 +1,10 @@
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { Injectable } from '@nestjs/common';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market';
import {
StockDto,
@@ -30,9 +32,9 @@ export class StockCatalogService {
data:
result.data?.map((item) => {
const short = stock(item);
const detail = item.detail
? stock({ ...item.detail, uid: item.uid })
: short;
const detail = item.detail ?
stock({ ...item.detail, uid: item.uid }) :
short;
const last = item.price;
return {
...detail,
@@ -40,17 +42,18 @@ export class StockCatalogService {
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency
? {
last?.price && detail.currency ?
{
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
} :
null,
priceAt: timestamp(last?.time),
};
}) ?? null,
};
}
async get(
userId: string,
uid: string,
@@ -59,11 +62,12 @@ export class StockCatalogService {
const result = await this.discovery.detail(userId, 'share', uid, observe);
return {
...result,
data: result.data
? { ...stock(result.data), slug: result.data.slug }
: null,
data: result.data ?
{ ...stock(result.data), slug: result.data.slug } :
null,
};
}
async getByTicker(
userId: string,
ticker: string,
@@ -77,9 +81,9 @@ export class StockCatalogService {
);
return {
...result,
data: result.data
? { ...stock(result.data), slug: result.data.slug }
: null,
data: result.data ?
{ ...stock(result.data), slug: result.data.slug } :
null,
};
}
}
@@ -1,27 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
decimalFromString,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
TbankAccessService,
type TbankDividend,
} from '../../../../../integrations/tbank/tbank.api';
import {
PortfolioService,
ForecastService,
PortfolioService,
} from '../../../portfolio/portfolio.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import {
StockDividendDto,
StockDividendsDto,
StockDividendsResourceDto,
StockPositionDto,
StockDividendDto,
} from '../presentation/stock.dto';
import { decimal } from '../../../../../integrations/tbank/decimal';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@Injectable()
@@ -40,6 +41,7 @@ export class StockIncomeService {
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
};
}
async dividends(
userId: string,
uid: string,
@@ -58,21 +60,22 @@ export class StockIncomeService {
['detail', 'dividends'],
{ from, to },
);
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
throw new NotFoundException();
}
const events = sources[1].payload?.dividends;
const position = portfolio.data
? instrumentPosition(portfolio.data, uid, 'STOCK')
: null;
const projected = events
? stockDividends(
const position = portfolio.data ?
instrumentPosition(portfolio.data, uid, 'STOCK') :
null;
const projected = events ?
stockDividends(
events,
position,
{ from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data,
)
: undefined;
) :
undefined;
const result = await this.forecasts.result({
userId,
revision: connection.revision,
@@ -83,9 +86,9 @@ export class StockIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
return result.data || !projected
? result
: {
return result.data || !projected ?
result :
{
data: stockDividends(
events ?? [],
null,
@@ -100,7 +103,7 @@ export class StockIncomeService {
export function stockDividends(
events: TbankDividend[],
position: StockPositionDto | null,
position: null | StockPositionDto,
window: { from: string; to: string; today: string },
observedAt: string,
positionUnavailable: boolean,
@@ -109,9 +112,9 @@ export function stockDividends(
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
decimalFromString(position.quantity) :
null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
@@ -120,29 +123,29 @@ export function stockDividends(
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null;
currency && event.dividendNet ?
{ currency, value: formatDecimal(decimal(event.dividendNet)) } :
null;
// 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 timing: StockDividendDto['timing'] = eventDate
? eventDate >= today
? 'upcoming'
: 'past'
: 'undated';
const timing: StockDividendDto['timing'] = eventDate ?
eventDate >= today ?
'upcoming' :
'past' :
'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming'
? {
timing === 'upcoming' ?
{
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
}
: null;
} :
null;
return {
perShare,
recordDate,
@@ -1,9 +1,5 @@
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
@@ -12,6 +8,11 @@ import {
subtract,
} from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { timestamp } from '../../application/instrument-market';
import {
StockCandlesQueryDto,
@@ -26,6 +27,7 @@ export class StockMarketService {
private readonly history: CandleService,
private readonly sources: CatalogEnrichmentService,
) {}
async quote(
userId: string,
uid: string,
@@ -39,12 +41,13 @@ export class StockMarketService {
observe,
);
return {
data: result.values.instrument
? stockQuote(result.values, result.unavailable, result.observedAt)
: null,
data: result.values.instrument ?
stockQuote(result.values, result.unavailable, result.observedAt) :
null,
meta: result.meta,
};
}
candles(
userId: string,
uid: string,
@@ -69,20 +72,20 @@ export function stockQuote(
const priceAt = timestamp(last?.time);
const currency = share?.currency?.toUpperCase();
const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
last?.price && currency ?
{ currency, value: formatDecimal(decimal(last.price)) } :
null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
previous?.close && currency ?
{ currency, value: formatDecimal(decimal(previous.close)) } :
null;
const difference =
price && previousPrice
? subtract(
price && previousPrice ?
subtract(
decimalFromString(price.value),
decimalFromString(previousPrice.value),
)
: null;
) :
null;
return {
price,
priceAt,
@@ -90,21 +93,21 @@ export function stockQuote(
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
previousClose: previousPrice,
change:
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
difference && currency ?
{ currency, value: formatDecimal(difference) } :
null,
changePercent:
difference &&
previousPrice &&
decimalFromString(previousPrice.value).value > 0n
? formatDecimal(
decimalFromString(previousPrice.value).value > 0n ?
formatDecimal(
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
CHANGE_PERCENT_DECIMAL_PLACES,
),
)
: null,
) :
null,
unavailable,
observedAt,
};
@@ -1,7 +1,7 @@
import { previousClose } from '../../application/instrument-market';
import { metrics } from './get-stock-company.use-case';
import { stockDividends } from './stock-income.service';
import { stockQuote } from './stock-market.service';
import { previousClose } from '../../application/instrument-market';
jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
@@ -1,4 +1,5 @@
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import type { Request } from 'express';
import {
Controller,
Get,
@@ -9,25 +10,26 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
import { StockCatalogService } from '../application/stock-catalog.service';
import { StockIncomeService } from '../application/stock-income.service';
import { StockMarketService } from '../application/stock-market.service';
import {
StockCandlesResourceDto,
StockCandlesQueryDto,
StockCandlesResourceDto,
StockCompanyResourceDto,
StockDividendsResourceDto,
StockResourceDto,
StockPositionResourceDto,
StockQuoteResourceDto,
StockResourceDto,
StockSearchQueryDto,
StockSearchResourceDto,
StockTickerParamsDto,
} from './stock.dto';
import { StockCatalogService } from '../application/stock-catalog.service';
@ApiTags('stocks')
@ApiProblemResponses()
@@ -41,6 +43,7 @@ export class StockController {
private readonly income: StockIncomeService,
private readonly companyInfo: GetStockCompanyUseCase,
) {}
@Get()
@ApiOkResponse({ type: StockSearchResourceDto })
search(
@@ -53,6 +56,7 @@ export class StockController {
query.sync === 'observe',
);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: StockResourceDto })
getByTicker(
@@ -66,6 +70,7 @@ export class StockController {
query.sync === 'observe',
);
}
@Get(':uid')
@ApiOkResponse({ type: StockResourceDto })
get(
@@ -75,6 +80,7 @@ export class StockController {
) {
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/quote')
@ApiOkResponse({ type: StockQuoteResourceDto })
quote(
@@ -84,6 +90,7 @@ export class StockController {
) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/candles')
@ApiOkResponse({ type: StockCandlesResourceDto })
candles(
@@ -98,6 +105,7 @@ export class StockController {
query.sync === 'observe',
);
}
@Get(':uid/position')
@ApiOkResponse({ type: StockPositionResourceDto })
position(
@@ -111,6 +119,7 @@ export class StockController {
query.sync === 'observe',
);
}
@Get(':uid/company')
@ApiOkResponse({ type: StockCompanyResourceDto })
company(
@@ -124,6 +133,7 @@ export class StockController {
query.sync === 'observe',
);
}
@Get(':uid/dividends')
@ApiOkResponse({ type: StockDividendsResourceDto })
dividends(
@@ -1,7 +1,3 @@
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../../investment-resource.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
@@ -12,7 +8,12 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { MoneyDto } from '../../../../../common/money.dto';
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../../investment-resource.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
@@ -36,7 +37,7 @@ export class StockTickerParamsDto {
@IsString()
@MinLength(1)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
declare ticker: string;
}
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
@@ -46,7 +47,7 @@ export class StockCandlesQueryDto extends InvestmentReadQueryDto {
})
@IsOptional()
@IsIn(['month', 'halfYear', 'year', 'all'])
period?: 'month' | 'halfYear' | 'year' | 'all';
period?: 'all' | 'halfYear' | 'month' | 'year';
}
export class StockDto {
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@@ -54,48 +55,63 @@ export class StockDto {
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
declare slug: string;
@ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) declare isin: null | string;
@ApiProperty({ type: String, nullable: true }) declare classCode:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
null | string;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: null | string;
@ApiProperty({ type: String, nullable: true }) declare logoName:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare shareType:
string | null;
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare sector: null | string;
@ApiProperty({ type: Number, nullable: true }) declare lot: null | number;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
declare historyFrom: null | string;
}
export class StockSearchResultDto extends StockDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
declare priceAt: null | string;
}
export class StockQuoteDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
declare priceAt: null | string;
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null;
null | string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare change: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare changePercent:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare previousCloseDate: string | null;
declare previousCloseDate: null | string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare previousClose: MoneyDto | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockCandleDto {
@@ -104,29 +120,36 @@ export class StockCandleDto {
@ApiProperty() declare isComplete: boolean;
}
export class StockCandlesDto {
@ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[];
@ApiProperty({ type: StockCandleDto, isArray: true }) declare candles: StockCandleDto[];
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
declare historyFrom: null | string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockPositionRowDto extends PortfolioPositionDto {
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
string | null;
null | string;
}
export class StockPositionDto {
@ApiProperty() declare quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
string | null;
@ApiProperty({ type: [StockPositionRowDto] })
null | string;
@ApiProperty({ type: StockPositionRowDto, isArray: true })
declare accounts: StockPositionRowDto[];
@ApiProperty() declare isStale: boolean;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
@@ -134,90 +157,108 @@ export class StockMetricDto {
@ApiProperty() declare key: string;
@ApiProperty() declare value: number;
@ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit:
'money' | 'ratio' | 'percent';
'money' | 'percent' | 'ratio';
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] })
declare period: 'TTM' | 'MRQ' | null;
declare period: 'MRQ' | 'TTM' | null;
}
export class StockCompanyDto {
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
@ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[];
@ApiProperty({ type: [String] }) declare unavailable: string[];
null | string;
@ApiProperty({ type: StockMetricDto, isArray: true }) declare metrics: StockMetricDto[];
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockDividendDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare perShare: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare recordDate: string | null;
declare recordDate: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare lastBuyDate: string | null;
declare lastBuyDate: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare paymentDate: string | null;
declare paymentDate: null | string;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated';
'past' | 'undated' | 'upcoming';
@ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null;
}
export class StockDividendsDto {
@ApiProperty({ type: [StockDividendDto] })
@ApiProperty({ type: StockDividendDto, isArray: true })
declare dividends: StockDividendDto[];
@ApiProperty({ format: 'date' }) declare from: string;
@ApiProperty({ format: 'date' }) declare to: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare positionAt: string | null;
declare positionAt: null | string;
@ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean;
}
export class StockPositionResourceDto {
@ApiProperty({ type: StockPositionDto, nullable: true })
declare data: StockPositionDto | null;
declare data: null | StockPositionDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockSearchResourceDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
@ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true })
declare data: StockSearchResultDto[] | null;
declare data: null | StockSearchResultDto[];
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockResourceDto {
@ApiProperty({ type: StockDto, nullable: true })
declare data: StockDto | null;
declare data: null | StockDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockQuoteResourceDto {
@ApiProperty({ type: StockQuoteDto, nullable: true })
declare data: StockQuoteDto | null;
declare data: null | StockQuoteDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockCompanyResourceDto {
@ApiProperty({ type: StockCompanyDto, nullable: true })
declare data: StockCompanyDto | null;
declare data: null | StockCompanyDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockCandlesResourceDto {
@ApiProperty({ type: StockCandlesDto, nullable: true })
declare data: StockCandlesDto | null;
declare data: null | StockCandlesDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
export class StockDividendsResourceDto {
@ApiProperty({ type: StockDividendsDto, nullable: true })
declare data: StockDividendsDto | null;
declare data: null | StockDividendsDto;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}