refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -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' },
});
});
});