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>({ max: 300, ttl: 30_000, }); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, ) {} search(userId: string, query: string): Promise { 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 { 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 { 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 { 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 { const share = await this.tbank.getShare(token, uid); if (!share || share.uid !== uid) throw new NotFoundException(); return { ...share, uid }; } cached( userId: string, key: string, operation: (token: string) => Promise, ): Promise { 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; 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(); 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), }; }