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.
154 lines
5.1 KiB
TypeScript
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,
|
|
}),
|
|
};
|
|
}
|
|
}
|