Files
backend/src/tbank/bond.service.ts
T
ksv741 e6f58ef0ee feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
2026-09-12 18:34:18 +03:00

588 lines
19 KiB
TypeScript

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<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;
}