Files
backend/src/modules/investments/instruments/application/catalog-discovery.service.ts
T
ksv741 83df5f0be6
Backend verify / commits (pull_request) Successful in 1m38s
Backend verify / security (pull_request) Successful in 1m28s
Backend verify / verify (pull_request) Successful in 8m40s
refactor!: support RUB only (WealthLedger/main#55)
Filter non-RUB data at ingest and collapse per-currency collections
into a single RUB Money. Drop currency from TbankPortfolioValuePoint,
remove NON_RUB_ACTIVITY and the screener currency filter.

BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
2026-11-11 09:40:25 +03:00

154 lines
5.1 KiB
TypeScript

import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
TbankAccessService,
TbankClient,
tbankSourceTtlMs,
} from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import {
catalogQuery,
type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository';
import {
catalogSearchLimit,
selectCatalogInstruments,
} from './catalog-selection';
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2;
@Injectable()
export class CatalogDiscoveryService {
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
async search(userId: string, input: CatalogQuery) {
return this.onlineSearch(userId, catalogQuery(input));
}
async detail(
userId: string,
instrumentType: 'bond' | 'share',
uid: string,
) {
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') 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,
}),
};
}
async byTicker(
userId: string,
instrumentType: 'bond' | 'share',
ticker: string,
) {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const candidates = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
);
const details = await this.access.online(userId, ({ token }) => Promise.all(
candidates.map((item) => instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid)),
));
const matches = candidates.filter((_item, index) =>
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
).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 };
}
private async onlineSearch(userId: string, input: CatalogQuery) {
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
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,
);
const [details, prices] = await Promise.all([
Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
),
instruments.length
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [],
]);
return { details, instruments, prices };
});
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
return {
data,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(input)}`,
dataRevision: `online:${fetchedAt.toISOString()}`,
availability: 'complete',
fetchedAt,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
}