144 lines
4.5 KiB
TypeScript
144 lines
4.5 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),
|
|
);
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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 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 };
|
|
}
|
|
|
|
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,
|
|
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
|
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);
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
}
|