refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MoexClient } from '../../../../../integrations/moex/moex.client';
|
||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
|
||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
|
||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
|
||||
import { BondIncomeService } from './bond-income.service';
|
||||
import { BondMarketService } from './bond-market.service';
|
||||
import { BondCatalogService } from './bond-catalog.service';
|
||||
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
const money = (units: string, currency = 'rub') => ({ units, currency });
|
||||
const cacheClock = performance;
|
||||
const bond = {
|
||||
uid: 'uid',
|
||||
ticker: 'BOND',
|
||||
isin: 'ISIN',
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
nominal: money('500'),
|
||||
initialNominal: money('1000'),
|
||||
aciValue: money('12'),
|
||||
amortizationFlag: true,
|
||||
first_1dayCandleDate: time('2024-01-01'),
|
||||
};
|
||||
function setup() {
|
||||
let generation = 'a';
|
||||
const tbank = {
|
||||
findBonds: jest.fn().mockResolvedValue([bond]),
|
||||
getBond: jest.fn().mockResolvedValue(bond),
|
||||
getLastPrice: jest.fn().mockResolvedValue({
|
||||
instrumentUid: 'uid',
|
||||
price: { units: '98', nano: 125000000 },
|
||||
time: time('2026-09-10T10:00:00Z'),
|
||||
}),
|
||||
getLastPrices: jest.fn().mockResolvedValue([]),
|
||||
getTradingStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'),
|
||||
getCandles: jest.fn().mockResolvedValue([
|
||||
{ time: time('2026-09-09'), close: { units: '99' }, isComplete: true },
|
||||
{ time: time('2026-09-10'), close: { units: '100' }, isComplete: true },
|
||||
]),
|
||||
getBondCoupons: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const access = {
|
||||
withConnection: jest.fn(
|
||||
(user: string, handler: (context: unknown) => unknown) =>
|
||||
handler({ token: user, generation }),
|
||||
),
|
||||
};
|
||||
const portfolios = {
|
||||
getAll: jest.fn().mockResolvedValue({
|
||||
positions: [],
|
||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||
isStale: false,
|
||||
}),
|
||||
};
|
||||
const ratings = { rating: jest.fn().mockResolvedValue(null) };
|
||||
const moex = { bondYield: jest.fn().mockResolvedValue(null) };
|
||||
const service = new BondCatalogService(
|
||||
access as unknown as TbankAccessService,
|
||||
tbank as unknown as TbankClient,
|
||||
);
|
||||
const market = new BondMarketService(
|
||||
service,
|
||||
tbank as unknown as TbankClient,
|
||||
);
|
||||
const income = new BondIncomeService(
|
||||
service,
|
||||
tbank as unknown as TbankClient,
|
||||
portfolios as unknown as PortfolioService,
|
||||
market,
|
||||
moex as unknown as MoexClient,
|
||||
ratings as unknown as NcrRatingsClient,
|
||||
);
|
||||
return {
|
||||
market,
|
||||
income,
|
||||
service,
|
||||
ratings,
|
||||
moex,
|
||||
tbank,
|
||||
access,
|
||||
portfolios,
|
||||
changeGeneration: () => {
|
||||
generation = 'b';
|
||||
},
|
||||
};
|
||||
}
|
||||
describe('bond market use case', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
|
||||
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => {
|
||||
const { tbank, moex, market, income } = setup();
|
||||
tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
|
||||
tbank.getBondCoupons.mockResolvedValue([
|
||||
{
|
||||
couponDate: time('2026-12-10'),
|
||||
payOneBond: money('40'),
|
||||
couponPeriod: 91,
|
||||
couponType: 'COUPON_TYPE_VARIABLE',
|
||||
},
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond: money('30'),
|
||||
couponPeriod: 90,
|
||||
couponType: 'COUPON_TYPE_FIX',
|
||||
},
|
||||
{
|
||||
couponDate: time('2026-09-01'),
|
||||
payOneBond: money('90'),
|
||||
couponPeriod: 90,
|
||||
},
|
||||
]);
|
||||
const effectiveYield = {
|
||||
value: '15.6262',
|
||||
pricePercent: '53.384',
|
||||
date: '2041-05-15',
|
||||
dateType: 'MATDATE',
|
||||
tradeAt: '2026-09-10T18:49:39Z',
|
||||
};
|
||||
moex.bondYield.mockResolvedValue(effectiveYield);
|
||||
const [metrics] = await Promise.all([
|
||||
income.metrics('user', 'uid'),
|
||||
income.coupons('user', 'uid'),
|
||||
market.quote('user', 'uid'),
|
||||
]);
|
||||
expect(metrics).toMatchObject({
|
||||
coupon: { value: '30', currency: 'RUB' },
|
||||
couponDate: '2026-10-10',
|
||||
couponType: 'COUPON_TYPE_FIX',
|
||||
couponPeriodDays: 90,
|
||||
couponYieldPercent: '24.7983',
|
||||
effectiveYield,
|
||||
});
|
||||
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB');
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getLastPrice).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
||||
'does not invent coupon yield with %s inputs',
|
||||
async (scenario) => {
|
||||
const { tbank, income } = setup();
|
||||
tbank.getBondCoupons.mockResolvedValue([
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond:
|
||||
scenario === 'unknown'
|
||||
? undefined
|
||||
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||
},
|
||||
{
|
||||
couponDate: time('2026-12-10'),
|
||||
payOneBond: money('50'),
|
||||
couponPeriod: 90,
|
||||
},
|
||||
]);
|
||||
if (scenario === 'zero-price')
|
||||
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
|
||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
||||
couponDate: '2026-10-10',
|
||||
couponYieldPercent: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => {
|
||||
const { tbank, moex, income } = setup();
|
||||
tbank.getBond.mockResolvedValue({
|
||||
...bond,
|
||||
classCode: 'TQOB',
|
||||
floatingCouponFlag: true,
|
||||
riskLevel: 'RISK_LEVEL_LOW',
|
||||
});
|
||||
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
|
||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
||||
effectiveYield: null,
|
||||
coupon: null,
|
||||
couponType: 'COUPON_TYPE_FLOATING',
|
||||
couponYieldPercent: null,
|
||||
unavailable: ['yield'],
|
||||
});
|
||||
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
|
||||
await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
|
||||
const { ratings, income } = setup();
|
||||
const creditRating = {
|
||||
agency: 'NCR',
|
||||
value: 'AAA.ru',
|
||||
status: 'active',
|
||||
date: '2026-04-17',
|
||||
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
|
||||
observedAt: '2026-09-10T10:00:00Z',
|
||||
};
|
||||
ratings.rating.mockResolvedValue(creditRating);
|
||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
||||
creditRating,
|
||||
});
|
||||
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
|
||||
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
|
||||
expect(await income.metrics('other', 'uid')).toMatchObject({
|
||||
creditRating: null,
|
||||
unavailable: ['rating'],
|
||||
});
|
||||
});
|
||||
it('opens concurrent sections within one upstream metadata request budget', async () => {
|
||||
const { service, tbank, market, income } = setup();
|
||||
tbank.getBond
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(bond)
|
||||
.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(
|
||||
Promise.all([
|
||||
service.getByTicker('user', 'BOND'),
|
||||
market.quote('user', 'uid'),
|
||||
market.candles('user', 'uid'),
|
||||
income.coupons('user', 'uid'),
|
||||
income.metrics('user', 'uid'),
|
||||
]),
|
||||
).resolves.toHaveLength(5);
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('searches thirty issues without a separate ticker lookup for each result', async () => {
|
||||
const { service, tbank, market, income } = setup();
|
||||
const issues = Array.from({ length: 30 }, (_, index) => ({
|
||||
...bond,
|
||||
uid: `uid-${index}`,
|
||||
isin: `ISIN-${index}`,
|
||||
ticker: `BOND-${index}`,
|
||||
}));
|
||||
tbank.findBonds.mockResolvedValue(issues);
|
||||
tbank.getBond.mockImplementation((_token: string, uid: string) =>
|
||||
Promise.resolve(issues.find((item) => item.uid === uid)),
|
||||
);
|
||||
expect(await service.search('user', 'BOND')).toHaveLength(30);
|
||||
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(30);
|
||||
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
|
||||
await service.getByTicker('user', 'BOND-0');
|
||||
await Promise.all([
|
||||
market.quote('user', 'uid-0'),
|
||||
market.candles('user', 'uid-0'),
|
||||
income.coupons('user', 'uid-0'),
|
||||
]);
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(30);
|
||||
expect(tbank.findBonds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('refreshes current nominal and accrued interest after the metadata TTL', async () => {
|
||||
const { service, tbank, market } = setup();
|
||||
await service.get('user', 'uid');
|
||||
await jest.advanceTimersByTimeAsync(30_001);
|
||||
tbank.getBond.mockResolvedValue({
|
||||
...bond,
|
||||
nominal: money('400'),
|
||||
aciValue: money('3'),
|
||||
});
|
||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
||||
cleanPrice: { value: '392.5' },
|
||||
dirtyPrice: { value: '395.5' },
|
||||
});
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('keeps late metadata responses within their original connection generation', async () => {
|
||||
const { tbank, changeGeneration, market } = setup();
|
||||
let resolveOld: ((value: typeof bond) => void) | undefined;
|
||||
tbank.getBond.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
const oldQuote = market.quote('user', 'uid');
|
||||
changeGeneration();
|
||||
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
|
||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
||||
cleanPrice: { value: '392.5' },
|
||||
});
|
||||
assert.ok(resolveOld);
|
||||
resolveOld(bond);
|
||||
await oldQuote;
|
||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
||||
cleanPrice: { value: '392.5' },
|
||||
});
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
|
||||
const { market } = setup();
|
||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
||||
pricePercent: '98.125',
|
||||
cleanPrice: { value: '490.625', currency: 'RUB' },
|
||||
aci: { value: '12', currency: 'RUB' },
|
||||
dirtyPrice: { value: '502.625', currency: 'RUB' },
|
||||
changePercentagePoints: '-0.875',
|
||||
previousCloseDate: '2026-09-09',
|
||||
});
|
||||
});
|
||||
it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => {
|
||||
const { tbank, market } = setup();
|
||||
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
|
||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
||||
aci: null,
|
||||
dirtyPrice: null,
|
||||
});
|
||||
tbank.getBond.mockResolvedValueOnce({
|
||||
...bond,
|
||||
aciValue: money('12', 'usd'),
|
||||
});
|
||||
expect(await market.quote('other', 'uid')).toMatchObject({
|
||||
dirtyPrice: null,
|
||||
});
|
||||
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
|
||||
expect(await market.quote('third', 'uid')).toMatchObject({
|
||||
pricePercent: '98.125',
|
||||
cleanPrice: null,
|
||||
dirtyPrice: null,
|
||||
});
|
||||
});
|
||||
it('preserves absent coupon flags and dates without reporting false or zero', async () => {
|
||||
const { service } = setup();
|
||||
expect(await service.get('user', 'uid')).toMatchObject({
|
||||
floatingCoupon: null,
|
||||
perpetual: null,
|
||||
couponQuantityPerYear: null,
|
||||
maturityDate: null,
|
||||
nominal: { value: '500', currency: 'RUB' },
|
||||
amortization: true,
|
||||
});
|
||||
});
|
||||
it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => {
|
||||
const { service, tbank } = setup();
|
||||
const listings = [
|
||||
{ ...bond, uid: 'off', apiTradeAvailableFlag: false },
|
||||
{ ...bond, uid: 'uid', apiTradeAvailableFlag: true },
|
||||
{ ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' },
|
||||
{ ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
||||
{ ...bond, uid: undefined },
|
||||
{ ...bond, uid: '' },
|
||||
];
|
||||
tbank.findBonds.mockResolvedValue(listings);
|
||||
tbank.getBond.mockImplementation((_token: string, uid: string) =>
|
||||
Promise.resolve(listings.find((b) => b.uid === uid)),
|
||||
);
|
||||
tbank.getLastPrices.mockResolvedValue([
|
||||
{ instrumentUid: 'uid', price: { units: '99' } },
|
||||
{ instrumentUid: 'off', price: { units: '1' } },
|
||||
]);
|
||||
const results = await service.search('user', 'BOND');
|
||||
expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']);
|
||||
expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' });
|
||||
expect((await service.get('user', 'off')).slug).toBe('off');
|
||||
});
|
||||
it('rejects ambiguous tickers and wrong instrument identities', async () => {
|
||||
const { service, tbank } = setup();
|
||||
tbank.findBonds.mockResolvedValue([
|
||||
bond,
|
||||
{ ...bond, uid: 'other', isin: 'OTHER' },
|
||||
]);
|
||||
await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' });
|
||||
await expect(service.get('user', 'uid')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => {
|
||||
const { service, tbank } = setup();
|
||||
tbank.getBond.mockRejectedValue(new ServiceUnavailableException());
|
||||
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
|
||||
expect(await service.search('user', 'BOND')).toMatchObject([
|
||||
{ instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } },
|
||||
]);
|
||||
tbank.getBond.mockRejectedValue(new UnauthorizedException());
|
||||
await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
|
||||
const { tbank, market } = setup();
|
||||
const history = await market.candles('user', 'uid', 'all');
|
||||
expect(history.currency).toBeNull();
|
||||
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
|
||||
for (const [, , from, to] of tbank.getCandles.mock.calls as [
|
||||
string,
|
||||
string,
|
||||
Date,
|
||||
Date,
|
||||
][])
|
||||
expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000);
|
||||
tbank.getBond.mockResolvedValue({
|
||||
...bond,
|
||||
first_1dayCandleDate: undefined,
|
||||
});
|
||||
await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
it('isolates caches by user and connection generation and retries failures', async () => {
|
||||
const { service, tbank, changeGeneration } = setup();
|
||||
await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]);
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(1);
|
||||
await service.get('b', 'uid');
|
||||
changeGeneration();
|
||||
await service.get('a', 'uid');
|
||||
expect(tbank.getBond).toHaveBeenCalledTimes(3);
|
||||
tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException());
|
||||
await expect(service.get('c', 'uid')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
await expect(service.get('c', 'uid')).resolves.toMatchObject({
|
||||
instrumentUid: 'uid',
|
||||
});
|
||||
});
|
||||
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => {
|
||||
const { tbank, portfolios, income } = setup();
|
||||
const holding = {
|
||||
instrumentUid: 'uid',
|
||||
type: 'BOND',
|
||||
quantity: '2',
|
||||
averagePurchasePrice: { value: '490', currency: 'RUB' },
|
||||
currentValue: { value: '981.25', currency: 'RUB' },
|
||||
absoluteReturn: { value: '1.25', currency: 'RUB' },
|
||||
};
|
||||
portfolios.getAll.mockResolvedValue({
|
||||
positions: [
|
||||
holding,
|
||||
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
||||
{ ...holding, type: 'STOCK', quantity: '999' },
|
||||
],
|
||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||
isStale: true,
|
||||
});
|
||||
tbank.getBondCoupons.mockResolvedValue([
|
||||
{
|
||||
couponNumber: '1',
|
||||
couponDate: time('2026-10-01'),
|
||||
fixDate: time('2026-09-29'),
|
||||
payOneBond: money('10'),
|
||||
},
|
||||
{
|
||||
couponNumber: '2',
|
||||
couponDate: time('2026-10-01'),
|
||||
fixDate: time('2026-09-09'),
|
||||
payOneBond: money('10'),
|
||||
},
|
||||
{
|
||||
couponNumber: '3',
|
||||
couponDate: time('2026-10-01'),
|
||||
payOneBond: money('10'),
|
||||
},
|
||||
{
|
||||
couponNumber: '4',
|
||||
couponDate: time('2026-10-01'),
|
||||
fixDate: time('2026-09-29'),
|
||||
},
|
||||
{
|
||||
couponNumber: '5',
|
||||
couponDate: time('2025-10-01'),
|
||||
payOneBond: money('10'),
|
||||
},
|
||||
]);
|
||||
const result = await income.coupons('user', 'uid');
|
||||
expect(
|
||||
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
|
||||
).toEqual({ value: '20', currency: 'RUB' });
|
||||
expect(
|
||||
result.coupons
|
||||
.filter((c) => c.number !== '1')
|
||||
.every((c) => c.estimatedAmount === null),
|
||||
).toBe(true);
|
||||
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
|
||||
expect(result.positionIsStale).toBe(true);
|
||||
expect(await income.position('user', 'uid')).toMatchObject({
|
||||
quantity: '2',
|
||||
currentValue: { value: '981.25' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user