Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
588 lines
19 KiB
TypeScript
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;
|
|
}
|