feat: add bond details, yields and credit ratings
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
import {
|
||||
timestamp,
|
||||
moscowDay,
|
||||
optional,
|
||||
previousClose,
|
||||
} from './instrument-market';
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import {
|
||||
decimal,
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
add,
|
||||
} 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<string, Promise<object>>({
|
||||
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<BondSearchResultDto[]> {
|
||||
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<BondDto> {
|
||||
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<BondDto> {
|
||||
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<TbankBond> {
|
||||
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<BondQuoteDto> {
|
||||
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<BondMetricsDto> {
|
||||
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<BondCouponsDto> {
|
||||
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<BondCandlesDto> {
|
||||
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<string, BondCandlesDto['candles'][number]>();
|
||||
// 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<BondPositionDto> {
|
||||
return this.cached(userId, `position:${uid}`, async () =>
|
||||
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
|
||||
);
|
||||
}
|
||||
private bond(
|
||||
userId: string,
|
||||
connection: TbankConnectionContext,
|
||||
uid: string,
|
||||
): Promise<TbankBond> {
|
||||
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<T extends object>(
|
||||
userId: string,
|
||||
key: string,
|
||||
operation: (connection: TbankConnectionContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.access.withConnection(userId, (connection) =>
|
||||
this.cachedForConnection(userId, connection, key, () =>
|
||||
operation(connection),
|
||||
),
|
||||
);
|
||||
}
|
||||
private cachedForConnection<T extends object>(
|
||||
userId: string,
|
||||
{ generation }: TbankConnectionContext,
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cacheKey = JSON.stringify([userId, generation, key]);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) return cached as Promise<T>;
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user