Files
backend/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts
T

203 lines
6.9 KiB
TypeScript

import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import {
TbankAccessService,
TbankClient,
} from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
const SEARCH_RESULTS_LIMIT = 30;
type IdentifiedShare = TbankShare & { uid: string };
@Injectable()
export class StockCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
Promise.all(
shares.map(
async (share) =>
(await optional(
() =>
this.get(userId, share.uid).catch((error: unknown) => {
if (error instanceof NotFoundException) return stock(share);
throw error;
}),
'details',
[],
)) ?? stock(share),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
shares.map((share) => share.uid),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = stock(shares[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency
? {
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<StockDto> {
return this.cached(userId, `share:${uid}`, async (token) => {
const result = stock(await this.share(token, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(
userId: string,
ticker: string,
): Promise<IdentifiedShare> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (token) => {
const found = await this.tbank.findShares(token, normalized);
const matches = selectShares(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
return matches[0];
});
}
async share(token: string, uid: string): Promise<IdentifiedShare> {
const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException();
return { ...share, uid };
}
cached<T extends object>(
userId: string,
key: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, ({ token, generation }) => {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation(token);
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
});
}
}
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
const seen = new Set<string>();
return found
.filter(
(share): share is IdentifiedShare =>
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
a.uid.localeCompare(b.uid),
)
.filter((share) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = share.isin?.trim()
? `isin:${share.isin.trim().toUpperCase()}`
: `uid:${share.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stock(share: IdentifiedShare): StockDto {
return {
instrumentUid: share.uid,
slug: share.uid,
name: share.name?.trim() || share.ticker || share.uid,
ticker: share.ticker?.trim() || null,
isin: share.isin?.trim() || null,
classCode: share.classCode?.trim() || null,
realExchange: share.realExchange || null,
apiTradeAvailable: share.apiTradeAvailableFlag ?? null,
logoName: share.brand?.logoName?.trim() || null,
currency: share.currency?.toUpperCase() || null,
shareType: share.shareType || null,
sector: share.sector || null,
lot: share.lot && share.lot > 0 ? share.lot : null,
historyFrom: timestamp(share.first_1dayCandleDate),
};
}