import { Injectable, ConflictException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { decimal, decimalFromString, divide, formatDecimal, multiply, subtract, } from './decimal'; import { PortfolioService } from './portfolio.service'; import { stockPosition } from './stock-position'; import { TbankAccessService } from './tbank-access.service'; import { TbankClient, TbankTimestamp } from './tbank.client'; import type { TbankCandle, TbankFundamentals, TbankShare, } from './tbank-market.types'; import { StockCandlesDto, StockCandlesQueryDto, StockCompanyDto, StockDividendDto, StockDividendsDto, StockDto, StockMetricDto, StockPositionDto, StockQuoteDto, StockSearchResultDto, } from './stock.dto'; const dayMs = 86_400_000; @Injectable() export class StockService { private readonly cache = new LRUCache>({ max: 300, ttl: 30_000, }); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly portfolios: PortfolioService, ) {} 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, 30); 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]; }); } quote(userId: string, uid: string): Promise { return this.cached(userId, `quote:${uid}`, async (token) => { const share = await this.share(token, uid); const unavailable: string[] = []; const [last, tradingStatus] = await Promise.all([ optional( () => this.tbank.getLastPrice(token, uid), 'price', unavailable, ), optional( () => this.tbank.getTradingStatus(token, uid), 'status', unavailable, ), ]); const priceAt = timestamp(last?.time); const currency = share.currency?.toUpperCase(); const price = last?.price && currency ? { currency, value: formatDecimal(decimal(last.price)) } : null; const previous = priceAt ? await optional( async () => { const end = new Date(priceAt); return previousClose( await this.tbank.getCandles( token, uid, new Date(end.valueOf() - 20 * dayMs), end, ), priceAt, ); }, 'comparison', unavailable, ) : null; const previousPrice = previous?.close && currency ? { currency, value: formatDecimal(decimal(previous.close)) } : null; const difference = price && previousPrice ? subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), ) : null; return { price, priceAt, tradingStatus, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousClose: previousPrice, change: difference && currency ? { currency, value: formatDecimal(difference) } : null, changePercent: difference && previousPrice && decimalFromString(previousPrice.value).value > 0n ? formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), 6, ), ) : null, unavailable, observedAt: new Date().toISOString(), }; }); } candles( userId: string, uid: string, period: StockCandlesQueryDto['period'] = 'month', ): Promise { return this.cached(userId, `candles:${uid}:${period}`, async (token) => { const share = await this.share(token, uid); const historyFrom = timestamp(share.first_1dayCandleDate); const to = new Date(); const from = new Date(to); from.setUTCMonth( from.getUTCMonth() - (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), ); if (period === 'all') { if (!historyFrom) throw new ServiceUnavailableException({ code: 'STOCK_HISTORY_START_UNAVAILABLE', }); from.setTime(Date.parse(historyFrom)); } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) from.setTime(Date.parse(historyFrom)); const collected = new Map(); // Conservative one-year windows also work with older deployed RPC limits. for (let start = from.valueOf(); start < to.valueOf();) { const end = Math.min(start + 365 * dayMs, to.valueOf()); for (const candle of await this.tbank.getCandles( token, uid, new Date(start), new Date(end), )) { const time = timestamp(candle.time); if ( time && candle.close && Date.parse(time) >= from.valueOf() - dayMs && Date.parse(time) <= to.valueOf() ) collected.set(time, { time, close: formatDecimal(decimal(candle.close)), isComplete: candle.isComplete === true, }); } start = end; } return { candles: [...collected.values()].sort((a, b) => a.time.localeCompare(b.time), ), currency: share.currency?.toUpperCase() || null, historyFrom, observedAt: new Date().toISOString(), }; }); } async position(userId: string, uid: string): Promise { return this.cached(userId, `position:${uid}`, async () => stockPosition(await this.portfolios.getAll(userId), uid), ); } company(userId: string, uid: string): Promise { return this.cached(userId, `company:${uid}`, async (token) => { const share = await this.share(token, uid); const unavailable: string[] = []; if (!share.assetUid) return { description: null, metrics: [], unavailable, observedAt: new Date().toISOString(), }; const [description, fundamentals] = await Promise.all([ optional( () => this.tbank.getAssetDescription(token, share.assetUid!), 'description', unavailable, ), optional( () => this.tbank.getFundamentals(token, share.assetUid!), 'fundamentals', unavailable, ), ]); return { description, metrics: metrics(fundamentals, share.sector), unavailable, observedAt: new Date().toISOString(), }; }); } dividends(userId: string, uid: string): Promise { return this.cached(userId, `dividends:${uid}`, async (token) => { await this.share(token, uid); const today = moscowDay(new Date().toISOString()); const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; const unavailable: string[] = []; const [events, position] = await Promise.all([ this.tbank.getDividends(token, uid, from, to), optional(() => this.position(userId, uid), 'position', unavailable), ]); const quantity = position && position.accounts.length && position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? decimalFromString(position.quantity) : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = currency && event.dividendNet ? { currency, value: formatDecimal(decimal(event.dividendNet)) } : null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const timing: StockDividendDto['timing'] = (paymentDate ?? recordDate) ? (paymentDate ?? recordDate)! >= today ? 'upcoming' : 'past' : 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && timing === 'upcoming' ? { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), } : null; return { perShare, recordDate, lastBuyDate, paymentDate, timing, estimatedAmount, }; }) .sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? '')); return { dividends, from, to, observedAt: new Date().toISOString(), positionAt: quantity ? (position?.observedAt ?? null) : null, estimateUnavailable: unavailable.length > 0, positionIsStale: position?.isStale ?? false, }; }); } private 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; } private 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): TbankShare[] { const seen = new Set(); return found .filter( (share) => 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: TbankShare): 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), }; } function timestamp(value?: TbankTimestamp): string | null { const seconds = Number(value?.seconds); return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null; } function moscowDay(iso: string): string { return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); } export function previousClose( candles: TbankCandle[], priceAt: string, ): TbankCandle | null { return ( candles .filter((candle) => { const time = timestamp(candle.time); return ( candle.isComplete && candle.close && time && time.slice(0, 10) < moscowDay(priceAt) ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? null ); } async function optional( operation: () => Promise, name: string, unavailable: string[], ): Promise { try { return await operation(); } catch (error) { if (!(error instanceof ServiceUnavailableException)) throw error; unavailable.push(name); return null; } } function metrics( values: TbankFundamentals | null, sector?: string, ): StockMetricDto[] { if (!values) return []; const bank = sector?.toLowerCase() === 'financial'; const selection: [ keyof TbankFundamentals, StockMetricDto['unit'], StockMetricDto['period'], ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], ...(bank ? ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], ] as typeof selection) : ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], ...(!bank ? ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], ] as typeof selection) : []), ]; return selection.flatMap(([key, unit, period]) => { const value = values[key]; const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && (unit !== 'money' || currency) ? [ { key, value, unit, period, currency: unit === 'money' ? currency : null, }, ] : []; }); }