import { timestamp, moscowDay, optional, previousClose, } from './instrument-market'; import { Injectable, ConflictException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { decimalFromString, divide, formatDecimal, multiply, subtract, add, } from '../common/decimal'; import { decimal } from './decimal'; import { NcrRatingsClient } from './ncr-ratings.client'; import { MoexClient } from './moex.client'; import { PortfolioService } from './portfolio.service'; import { instrumentPosition } from './instrument-position'; import { TbankAccessService, type TbankConnectionContext, } from './tbank-access.service'; import { TbankClient, type TbankMoney } from './tbank.client'; import type { TbankBond } from './tbank-market.types'; import { BondMetricsDto, BondCandlesDto, BondCandlesQueryDto, BondDto, BondPositionDto, BondQuoteDto, BondCouponsDto, BondSearchResultDto, } from './bond.dto'; const dayMs = 86_400_000; @Injectable() export class BondService { private readonly cache = new LRUCache>({ max: 300, ttl: 30_000, }); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly portfolios: PortfolioService, private readonly moex: MoexClient, private readonly ratings: NcrRatingsClient, ) {} search(userId: string, query: string): Promise { return this.cached(userId, `search:${query}`, async (connection) => { const { token } = connection; const found = await this.tbank.findBonds(token, query); const bonds = selectBonds(found, query).slice(0, 30); if (!bonds.length) return []; // Batch prices and share metadata with detail sections. Resolve readable // aliases only when an issue is opened, not for every search result. const [details, prices] = await Promise.all([ Promise.all( bonds.map( async (bond) => (await optional( () => this.bond(userId, connection, bond.uid!) .then(toBond) .catch((error: unknown) => { if (error instanceof NotFoundException) return toBond(bond); throw error; }), 'details', [], )) ?? toBond(bond), ), ), optional( () => this.tbank.getLastPrices( token, bonds.map((bond) => bond.uid!), ), 'prices', [], ), ]); return details.map((detail, index) => { const short = toBond(bonds[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, pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null, priceAt: timestamp(last?.time), }; }); }); } get(userId: string, uid: string): Promise { return this.cached(userId, `bond:${uid}`, async (connection) => { const result = toBond(await this.bond(userId, connection, 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 (connection) => { const { token } = connection; const found = await this.tbank.findBonds(token, normalized); const matches = selectBonds( found.filter( (item) => item.ticker?.trim().toUpperCase() === normalized, ), normalized, ); if (!matches.length) throw new NotFoundException(); if (matches.length > 1) throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }); return matches[0]; }); } quote(userId: string, uid: string): Promise { return this.cached(userId, `quote:${uid}`, async (connection) => { const { token } = connection; const instrument = await this.bond(userId, connection, 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 pricePercent = last?.price ? formatDecimal(decimal(last.price)) : null; const nominal = money(instrument.nominal); const aci = money(instrument.aciValue); const cleanPrice = pricePercent !== null && nominal && decimalFromString(nominal.value).value > 0n ? { currency: nominal.currency, value: formatDecimal( divide( multiply( decimalFromString(pricePercent), decimalFromString(nominal.value), ), decimalFromString('100'), 9, ), ), } : null; const dirtyPrice = cleanPrice && aci && aci.currency === cleanPrice.currency ? { currency: cleanPrice.currency, value: formatDecimal( add( decimalFromString(cleanPrice.value), decimalFromString(aci.value), ), ), } : null; const previous = priceAt ? await optional( async () => previousClose( await this.tbank.getCandles( token, uid, new Date(Date.parse(priceAt) - 20 * dayMs), new Date(priceAt), ), priceAt, ), 'comparison', unavailable, ) : null; return { pricePercent, cleanPrice, aci, dirtyPrice, priceAt, tradingStatus, changePercentagePoints: last?.price && previous?.close ? formatDecimal( subtract(decimal(last.price), decimal(previous.close)), ) : null, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, unavailable, observedAt: new Date().toISOString(), }; }); } metrics(userId: string, uid: string): Promise { return this.cached(userId, `metrics:${uid}`, async (connection) => { const instrument = await this.bond(userId, connection, 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, quote, effectiveYield, creditRating] = await Promise.all([ optional( () => this.couponEvents(userId, connection, uid, from, to), 'coupons', unavailable, ), optional(() => this.quote(userId, uid), 'quote', unavailable), instrument.ticker && instrument.isin && instrument.classCode ? optional( () => this.moex.bondYield( instrument.ticker!, instrument.isin!, instrument.classCode!, ), 'yield', unavailable, ) : null, instrument.isin ? optional( () => this.ratings.rating(instrument.isin!), 'rating', unavailable, ) : null, ]); const next = events ?.filter((event) => { const date = timestamp(event.couponDate)?.slice(0, 10); return date && date >= today; }) .sort( (a, b) => Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds), )[0]; const coupon = money(next?.payOneBond); const period = next?.couponPeriod; const couponPeriodDays = period && Number.isInteger(period) && period > 0 ? period : null; const clean = quote?.cleanPrice; const couponYieldPercent = coupon && couponPeriodDays && clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= 0n && decimalFromString(clean.value).value > 0n ? formatDecimal( divide( multiply( decimalFromString(coupon.value), decimalFromString('36500'), ), multiply( decimalFromString(String(couponPeriodDays)), decimalFromString(clean.value), ), 4, ), ) : null; const couponType = next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? next.couponType : instrument.floatingCouponFlag === true ? 'COUPON_TYPE_FLOATING' : null; return { creditRating, coupon, couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, couponType, couponPeriodDays, couponYieldPercent, couponYieldPriceAt: couponYieldPercent !== null ? (quote?.priceAt ?? null) : null, effectiveYield, unavailable, observedAt: new Date().toISOString(), }; }); } private couponEvents( userId: string, connection: TbankConnectionContext, uid: string, from: string, to: string, ) { return this.cachedForConnection( userId, connection, `coupon-events:${uid}:${from}:${to}`, () => this.tbank.getBondCoupons(connection.token, uid, from, to), ); } coupons(userId: string, uid: string): Promise { return this.cached(userId, `coupons:${uid}`, async (connection) => { await this.bond(userId, connection, 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.couponEvents(userId, connection, uid, from, to), optional(() => this.position(userId, uid), 'position', unavailable), ]); const quantity = position?.accounts.length && position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? decimalFromString(position.quantity) : null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const perBond = money(event.payOneBond); const timing = !paymentDate ? ('undated' as const) : paymentDate >= today ? ('upcoming' as const) : ('past' as const); return { number: event.couponNumber ?? null, paymentDate, recordDate, perBond, couponType: event.couponType ?? null, periodDays: event.couponPeriod ?? null, timing, estimatedAmount: quantity && perBond && timing === 'upcoming' && recordDate && recordDate >= today ? { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), } : null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''), ); return { coupons, from, to, observedAt: new Date().toISOString(), positionAt: quantity ? (position?.observedAt ?? null) : null, estimateUnavailable: unavailable.length > 0, positionIsStale: position?.isStale ?? false, }; }); } candles( userId: string, uid: string, period: BondCandlesQueryDto['period'] = 'month', ): Promise { return this.cached( userId, `candles:${uid}:${period}`, async (connection) => { const { token } = connection; const bond = await this.bond(userId, connection, uid); const historyFrom = timestamp(bond.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: 'BOND_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: null, historyFrom, observedAt: new Date().toISOString(), }; }, ); } async position(userId: string, uid: string): Promise { return this.cached(userId, `position:${uid}`, async () => instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'), ); } private bond( userId: string, connection: TbankConnectionContext, uid: string, ): Promise { return this.cachedForConnection( userId, connection, `metadata:${uid}`, async () => { const bond = await this.tbank.getBond(connection.token, uid); if (!bond || bond.uid !== uid) throw new NotFoundException(); return bond; }, ); } private cached( userId: string, key: string, operation: (connection: TbankConnectionContext) => Promise, ): Promise { return this.access.withConnection(userId, (connection) => this.cachedForConnection(userId, connection, key, () => operation(connection), ), ); } private cachedForConnection( userId: string, { generation }: TbankConnectionContext, key: string, operation: () => Promise, ): Promise { const cacheKey = JSON.stringify([userId, generation, key]); const cached = this.cache.get(cacheKey); if (cached) return cached as Promise; const pending = operation(); this.cache.set(cacheKey, pending); return pending.catch((error) => { if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey); throw error; }); } } function selectBonds(found: TbankBond[], query: string): TbankBond[] { const seen = new Set(); return found .filter( (bond) => bond.uid && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', ) .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 === 'TQOB') - Number(a.classCode === 'TQOB') || a.uid!.localeCompare(b.uid!), ) .filter((bond) => { // A missing ISIN must not collapse unrelated catalog instruments. const key = bond.isin?.trim() ? `isin:${bond.isin.trim().toUpperCase()}` : `uid:${bond.uid!}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function toBond(bond: TbankBond): BondDto { return { instrumentUid: bond.uid!, slug: bond.uid!, name: bond.name?.trim() || bond.ticker || bond.uid!, ticker: bond.ticker?.trim() || null, isin: bond.isin?.trim() || null, classCode: bond.classCode?.trim() || null, realExchange: bond.realExchange || null, apiTradeAvailable: bond.apiTradeAvailableFlag ?? null, logoName: bond.brand?.logoName?.trim() || null, currency: bond.currency?.toUpperCase() || null, bondType: bond.bondType || null, sector: bond.sector || null, lot: bond.lot && bond.lot > 0 ? bond.lot : null, historyFrom: timestamp(bond.first_1dayCandleDate), nominal: money(bond.nominal), initialNominal: money(bond.initialNominal), aci: money(bond.aciValue), maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, couponQuantityPerYear: bond.couponQuantityPerYear ?? null, floatingCoupon: bond.floatingCouponFlag ?? null, perpetual: bond.perpetualFlag ?? null, amortization: bond.amortizationFlag ?? null, subordinated: bond.subordinatedFlag ?? null, riskLevel: bond.riskLevel || null, }; } function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); return value && currency ? { currency, value: formatDecimal(decimal(value)) } : null; }