feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-09 09:04:41 +03:00
parent 03e75f8d2b
commit 0057925888
49 changed files with 1674 additions and 1870 deletions
@@ -9,8 +9,9 @@ import { StructuredLogger } from '../../../../common/logging/structured-logger.s
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
type TbankBond,
TbankClient,
tbankSourceNeedsRefresh,
type TbankShare,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
@@ -31,6 +32,7 @@ import {
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const MISSING_DATA_REVISION = '0';
const tickerAmbiguityLimit = 2;
@@ -73,13 +75,14 @@ export class CatalogDiscoveryService {
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: tbankSourceTtlMs.catalog,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input, observe);
const sources = discovery.data
? await this.enrichment.readMany(
@@ -124,7 +127,7 @@ export class CatalogDiscoveryService {
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.catalog,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
@@ -143,6 +146,25 @@ export class CatalogDiscoveryService {
uid: string,
observe = false,
) {
if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
const sources = await this.enrichment.readMany(
userId,
instrumentType,
@@ -182,7 +204,7 @@ export class CatalogDiscoveryService {
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
@@ -195,12 +217,37 @@ export class CatalogDiscoveryService {
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker(
userId: string,
instrumentType: 'bond' | 'share',
ticker: string,
observe = false,
) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId);
const found =
instrumentType === 'share'
? await this.tbank.findShares(token, query)
: await this.tbank.findBonds(token, query);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read(
userId,
{ instrumentType, query: ticker, exactTicker: true },
@@ -232,7 +279,7 @@ export class CatalogDiscoveryService {
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
@@ -245,6 +292,51 @@ export class CatalogDiscoveryService {
};
}
private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId);
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const details = await Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
);
const prices = instruments.length
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [];
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
});
return {
data,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(input)}`,
dataRevision: `online:${fetchedAt.toISOString()}`,
availability: 'complete',
fetchedAt,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
request(
userId: string,
revision: string,
@@ -255,10 +347,9 @@ export class CatalogDiscoveryService {
const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input);
if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
) {
return null;
}
!force &&
saved.dataRevision !== MISSING_DATA_REVISION
) return null;
const reservation = await this.repository.reserve(
{ userId, revision },
input,
@@ -404,3 +495,37 @@ export class CatalogDiscoveryService {
}
}
}
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -154,7 +154,7 @@ export class CatalogEnrichmentService {
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
work: byScope.get(catalogScope(input)) ?? null,
}),
};
@@ -186,14 +186,10 @@ export class CatalogEnrichmentService {
(entry) =>
entry.instrumentUid === input.uid && entry.kind === input.kind,
);
if (
!tbankSourceNeedsRefresh(
sourcePolicy(input.kind),
row?.fetchedAt ?? null,
new Date(),
force,
)
) {
const policy = sourcePolicy(input.kind);
const needsRefresh = force || !row?.fetchedAt ||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
if (!needsRefresh) {
continue;
}
const reserved = await this.repository.reserve(
@@ -234,6 +230,7 @@ export class CatalogEnrichmentService {
return reservation;
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section(
userId: string,
instrumentType: 'bond' | 'share',
@@ -241,6 +238,34 @@ export class CatalogEnrichmentService {
section: 'company' | 'metrics' | 'quote',
observe = false,
) {
if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid
? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid),
])
: [null, null];
const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: Number.MAX_SAFE_INTEGER,
work: null,
});
return {
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
meta,
unavailable: [],
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] =
section === 'company'
? ['detail', 'description', 'fundamentals']
@@ -287,10 +312,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt)
: null,
freshForMs:
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
work: null,
dependencies,
});
@@ -308,6 +330,7 @@ export class CatalogEnrichmentService {
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
private async load(
input: CatalogSource,
token: string,
@@ -323,7 +346,7 @@ export class CatalogEnrichmentService {
}
// 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 };
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
}
if (input.kind === 'trading-status') {
return {
@@ -470,6 +493,7 @@ export class CatalogEnrichmentService {
throw new Error('Unsupported catalog source');
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
private async drain() {
try {
while (this.queue.length) {
@@ -600,39 +624,6 @@ function sourceInputs(
})),
);
}
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: (value as TbankShare).shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
const previousCloseLookbackDays = 20;
const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
@@ -651,6 +642,40 @@ export type CatalogPayload = {
price?: null | TbankLastPrice;
tradingStatus?: null | string;
};
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
function sourcePolicy(kind: CatalogSource['kind']) {
if (
[
@@ -663,9 +688,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) {
return 'quote';
}
if (kind === 'description' || kind === 'fundamentals') return 'company';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata';
return 'stable';
}
function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' &&
@@ -71,8 +71,8 @@ export class BondIncomeService {
};
}
async readPosition(userId: string, uid: string, observe = false) {
const saved = await this.portfolios.read(userId, 'all', observe);
async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all');
return {
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
@@ -88,7 +88,7 @@ export class BondIncomeService {
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe);
const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany(
userId,
'bond',
@@ -119,13 +119,8 @@ export class BondController {
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.readPosition(
request.user.sub,
uid,
query.sync === 'observe',
);
return this.income.readPosition(request.user.sub, uid);
}
@Get(':uid/coupons')
@@ -40,8 +40,8 @@ export class StockIncomeService {
private readonly forecasts: ForecastService,
) {}
async readPosition(userId: string, uid: string, observe = false) {
const saved = await this.portfolios.read(userId, 'all', observe);
async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all');
return {
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
@@ -57,7 +57,7 @@ export class StockIncomeService {
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe);
const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany(
userId,
'share',
@@ -111,13 +111,8 @@ export class StockController {
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.readPosition(
request.user.sub,
uid,
query.sync === 'observe',
);
return this.income.readPosition(request.user.sub, uid);
}
@Get(':uid/company')