feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -1,205 +1,85 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankConnectionContext,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { optional, timestamp } from '../../application/instrument-market';
|
||||
import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
BondDto,
|
||||
BondResourceDto,
|
||||
BondSearchResourceDto,
|
||||
} from '../presentation/bond.dto';
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const SEARCH_RESULTS_LIMIT = 30;
|
||||
@Injectable()
|
||||
export class BondCatalogService {
|
||||
private readonly cache = new LRUCache<string, Promise<object>>({
|
||||
max: 300,
|
||||
ttl: 30_000,
|
||||
});
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
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, SEARCH_RESULTS_LIMIT);
|
||||
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);
|
||||
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<BondSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'bond', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...(await this.get(userId, match.uid)),
|
||||
slug: ticker.trim().toUpperCase(),
|
||||
...result,
|
||||
data:
|
||||
result.data?.map((item) => {
|
||||
const short = toBond(item);
|
||||
const detail = item.detail
|
||||
? toBond({ ...item.detail, uid: item.uid })
|
||||
: short;
|
||||
const last = item.price;
|
||||
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),
|
||||
};
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
private tickerMatch(userId: string, ticker: string): Promise<IdentifiedBond> {
|
||||
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];
|
||||
});
|
||||
}
|
||||
bond(
|
||||
async get(
|
||||
userId: string,
|
||||
connection: TbankConnectionContext,
|
||||
uid: string,
|
||||
): Promise<IdentifiedBond> {
|
||||
return this.cachedForConnection(
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
connection,
|
||||
`metadata:${uid}`,
|
||||
async () => {
|
||||
const bond = await this.tbank.getBond(connection.token, uid);
|
||||
if (!bond || bond.uid !== uid) throw new NotFoundException();
|
||||
return { ...bond, uid };
|
||||
},
|
||||
'bond',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
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): IdentifiedBond[] {
|
||||
const seen = new Set<string>();
|
||||
return found
|
||||
.filter(
|
||||
(bond): bond is IdentifiedBond =>
|
||||
Boolean(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: IdentifiedBond): BondDto {
|
||||
|
||||
@@ -1,208 +1,251 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
divide,
|
||||
} from '../../../../../common/decimal';
|
||||
import { MoexClient } from '../../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
|
||||
import {
|
||||
TbankClient,
|
||||
type TbankConnectionContext,
|
||||
TbankAccessService,
|
||||
type TbankCoupon,
|
||||
type TbankBond,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import { PortfolioService } from '../../../portfolio/portfolio.api';
|
||||
import {
|
||||
moscowDay,
|
||||
optional,
|
||||
timestamp,
|
||||
} from '../../application/instrument-market';
|
||||
PortfolioService,
|
||||
ForecastService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import {
|
||||
BondCouponsDto,
|
||||
BondMetricsDto,
|
||||
BondCouponsResourceDto,
|
||||
BondPositionDto,
|
||||
BondMetricsDto,
|
||||
BondMetricsResourceDto,
|
||||
} from '../presentation/bond.dto';
|
||||
|
||||
import { BondMarketService } from './bond-market.service';
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
import { BondCatalogService } from './bond-catalog.service';
|
||||
|
||||
import { bondQuote } from './bond-market.service';
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
const INCOME_HISTORY_YEARS = 5;
|
||||
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
||||
@Injectable()
|
||||
export class BondIncomeService {
|
||||
constructor(
|
||||
private readonly catalog: BondCatalogService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly portfolios: PortfolioService,
|
||||
private readonly market: BondMarketService,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly ratings: NcrRatingsClient,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly forecasts: ForecastService,
|
||||
) {}
|
||||
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
|
||||
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
|
||||
const instrument = await this.catalog.bond(userId, connection, uid);
|
||||
const { ticker, isin, classCode } = instrument;
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const unavailable: string[] = [];
|
||||
const [events, quote, effectiveYield, creditRating] = await Promise.all([
|
||||
optional(
|
||||
() => this.couponEvents(userId, connection, uid, from, to),
|
||||
'coupons',
|
||||
unavailable,
|
||||
),
|
||||
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
|
||||
ticker && isin && classCode
|
||||
? optional(
|
||||
() => this.moex.bondYield(ticker, isin, classCode),
|
||||
'yield',
|
||||
unavailable,
|
||||
)
|
||||
: null,
|
||||
isin
|
||||
? optional(() => this.ratings.rating(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),
|
||||
),
|
||||
COUPON_YIELD_DECIMAL_PLACES,
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
async metrics(
|
||||
userId: string,
|
||||
connection: TbankConnectionContext,
|
||||
uid: string,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
return this.catalog.cachedForConnection(
|
||||
observe = false,
|
||||
): Promise<BondMetricsResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
connection,
|
||||
`coupon-events:${uid}:${from}:${to}`,
|
||||
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
|
||||
'bond',
|
||||
uid,
|
||||
'metrics',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
|
||||
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
|
||||
await this.catalog.bond(userId, connection, uid);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
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,
|
||||
};
|
||||
|
||||
async readPosition(userId: string, uid: string, observe = false) {
|
||||
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||
return {
|
||||
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||
};
|
||||
}
|
||||
async coupons(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondCouponsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
'bond',
|
||||
[uid],
|
||||
observe,
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||
throw new NotFoundException();
|
||||
const events = sources[1].payload?.coupons;
|
||||
const position = portfolio.data
|
||||
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||
: null;
|
||||
const projected = events
|
||||
? bondCoupons(
|
||||
events,
|
||||
position,
|
||||
{ from, to, today },
|
||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
!portfolio.data,
|
||||
)
|
||||
: undefined;
|
||||
const result = await this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
membershipRevision: connection.membershipRevision,
|
||||
scopeKey: `bond/coupons/${JSON.stringify([uid, from, to])}`,
|
||||
portfolio: portfolio.meta,
|
||||
sources,
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
}
|
||||
async position(userId: string, uid: string): Promise<BondPositionDto> {
|
||||
return this.catalog.cached(userId, `position:${uid}`, async () =>
|
||||
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
|
||||
);
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
data: bondCoupons(
|
||||
events ?? [],
|
||||
null,
|
||||
{ from, to, today },
|
||||
projected.observedAt,
|
||||
true,
|
||||
),
|
||||
meta: { ...result.meta, availability: 'partial' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function bondCoupons(
|
||||
events: TbankCoupon[],
|
||||
position: BondPositionDto | null,
|
||||
window: { from: string; to: string; today: string },
|
||||
observedAt: string,
|
||||
positionUnavailable: boolean,
|
||||
): BondCouponsDto {
|
||||
const { from, to, today } = window;
|
||||
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: observedAt,
|
||||
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
||||
estimateUnavailable: positionUnavailable,
|
||||
positionIsStale: position?.isStale ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function bondMetrics(
|
||||
values: CatalogPayload,
|
||||
unavailable: string[],
|
||||
observedAt: string,
|
||||
): BondMetricsDto {
|
||||
const instrument = values.instrument as TbankBond;
|
||||
const events = values.coupons;
|
||||
const quote = bondQuote(values, unavailable, observedAt);
|
||||
const effectiveYield = values.effectiveYield ?? null;
|
||||
const creditRating = values.creditRating ?? null;
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const next = events
|
||||
?.filter((event) => {
|
||||
const date = timestamp(event.couponDate)?.slice(0, 10);
|
||||
return date && date >= today && date <= to;
|
||||
})
|
||||
.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),
|
||||
),
|
||||
COUPON_YIELD_DECIMAL_PLACES,
|
||||
),
|
||||
)
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -7,192 +13,110 @@ import {
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
optional,
|
||||
previousClose,
|
||||
timestamp,
|
||||
} from '../../application/instrument-market';
|
||||
import {
|
||||
BondCandlesDto,
|
||||
BondCandlesQueryDto,
|
||||
BondQuoteDto,
|
||||
BondQuoteResourceDto,
|
||||
} from '../presentation/bond.dto';
|
||||
|
||||
const dayMs = 86_400_000;
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
import { BondCatalogService } from './bond-catalog.service';
|
||||
|
||||
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
|
||||
const HALF_YEAR_MONTHS = 6;
|
||||
const YEAR_MONTHS = 12;
|
||||
const CANDLE_WINDOW_DAYS = 365;
|
||||
const PRICE_DECIMAL_PLACES = 9;
|
||||
@Injectable()
|
||||
export class BondMarketService {
|
||||
constructor(
|
||||
private readonly catalog: BondCatalogService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly history: CandleService,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
) {}
|
||||
quote(userId: string, uid: string): Promise<BondQuoteDto> {
|
||||
return this.catalog.cached(userId, `quote:${uid}`, async (connection) => {
|
||||
const { token } = connection;
|
||||
const instrument = await this.catalog.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'),
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
}
|
||||
: 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) - PREVIOUS_CLOSE_LOOKBACK_DAYS * 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(),
|
||||
};
|
||||
});
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
candles(
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: BondCandlesQueryDto['period'] = 'month',
|
||||
): Promise<BondCandlesDto> {
|
||||
return this.catalog.cached(
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
`candles:${uid}:${period}`,
|
||||
async (connection) => {
|
||||
const { token } = connection;
|
||||
const bond = await this.catalog.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'
|
||||
? HALF_YEAR_MONTHS
|
||||
: YEAR_MONTHS),
|
||||
);
|
||||
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 + CANDLE_WINDOW_DAYS * 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(),
|
||||
};
|
||||
},
|
||||
{ uid, instrumentType: 'bond', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function bondQuote(
|
||||
values: CatalogPayload,
|
||||
unavailable: string[],
|
||||
observedAt: string,
|
||||
): BondQuoteDto {
|
||||
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||
const instrument = values.instrument as TbankBond | undefined;
|
||||
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'),
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const dirtyPrice =
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||
? {
|
||||
currency: cleanPrice.currency,
|
||||
value: formatDecimal(
|
||||
add(
|
||||
decimalFromString(cleanPrice.value),
|
||||
decimalFromString(aci.value),
|
||||
),
|
||||
),
|
||||
}
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
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 {},
|
||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
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',
|
||||
@@ -30,469 +18,160 @@ const bond = {
|
||||
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,
|
||||
describe('bond financial projections', () => {
|
||||
beforeEach(() =>
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
||||
);
|
||||
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();
|
||||
afterEach(() => jest.useRealTimers());
|
||||
it('estimates only future unfixed payouts from exact UID holdings', () => {
|
||||
const holding = {
|
||||
instrumentUid: 'uid',
|
||||
type: 'BOND',
|
||||
quantity: '2',
|
||||
averagePurchasePrice: { value: '490', currency: 'RUB' },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { value: '981.25', currency: 'RUB' },
|
||||
absoluteReturn: { value: '1.25', currency: 'RUB' },
|
||||
absoluteReturn: null,
|
||||
};
|
||||
portfolios.getAll.mockResolvedValue({
|
||||
positions: [
|
||||
holding,
|
||||
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
||||
{ ...holding, type: 'STOCK', quantity: '999' },
|
||||
const position = instrumentPosition(
|
||||
{
|
||||
positions: [
|
||||
holding,
|
||||
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
||||
{ ...holding, type: 'STOCK', quantity: '999' },
|
||||
],
|
||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||
isStale: true,
|
||||
} as PortfolioDto,
|
||||
'uid',
|
||||
'BOND',
|
||||
);
|
||||
const result = bondCoupons(
|
||||
[
|
||||
{
|
||||
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'),
|
||||
},
|
||||
],
|
||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||
isStale: true,
|
||||
position,
|
||||
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
||||
'source-time',
|
||||
false,
|
||||
);
|
||||
expect(result.coupons[0].estimatedAmount).toEqual({
|
||||
value: '20',
|
||||
currency: 'RUB',
|
||||
});
|
||||
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),
|
||||
result.coupons.slice(1).every((row) => row.estimatedAmount === null),
|
||||
).toBe(true);
|
||||
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
|
||||
expect(result.coupons[3].perBond).toBeNull();
|
||||
expect(result.positionIsStale).toBe(true);
|
||||
expect(await income.position('user', 'uid')).toMatchObject({
|
||||
quantity: '2',
|
||||
currentValue: { value: '981.25' },
|
||||
expect(position.quantity).toBe('2');
|
||||
});
|
||||
it('preserves amortized nominal, ACI, percent quotes and annualized coupon yield', () => {
|
||||
const values = {
|
||||
instrument: bond,
|
||||
price: { price: { units: '98', nano: 125000000 } },
|
||||
candle: { close: { units: '99' } },
|
||||
coupons: [
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond: money('30'),
|
||||
couponPeriod: 90,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(bondQuote(values, [], 'time')).toMatchObject({
|
||||
pricePercent: '98.125',
|
||||
cleanPrice: { currency: 'RUB', value: '490.625' },
|
||||
dirtyPrice: { currency: 'RUB', value: '502.625' },
|
||||
changePercentagePoints: '-0.875',
|
||||
});
|
||||
expect(bondMetrics(values, [], 'time')).toMatchObject({
|
||||
couponDate: '2026-10-10',
|
||||
couponYieldPercent: '24.7983',
|
||||
creditRating: null,
|
||||
});
|
||||
});
|
||||
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
||||
'does not invent coupon yield with %s inputs',
|
||||
(scenario) => {
|
||||
const result = bondMetrics(
|
||||
{
|
||||
instrument: bond,
|
||||
price: { price: { units: scenario === 'zero-price' ? '0' : '100' } },
|
||||
coupons: [
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond:
|
||||
scenario === 'unknown'
|
||||
? undefined
|
||||
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||
},
|
||||
],
|
||||
},
|
||||
[],
|
||||
'time',
|
||||
);
|
||||
expect(result.couponYieldPercent).toBeNull();
|
||||
},
|
||||
);
|
||||
it('keeps absent and mismatched ACI unknown and never substitutes risk level for a rating', () => {
|
||||
expect(
|
||||
bondQuote(
|
||||
{
|
||||
instrument: { ...bond, aciValue: money('1', 'usd') },
|
||||
price: { price: { units: '100' } },
|
||||
},
|
||||
[],
|
||||
'time',
|
||||
).dirtyPrice,
|
||||
).toBeNull();
|
||||
expect(
|
||||
bondQuote(
|
||||
{
|
||||
instrument: { ...bond, aciValue: undefined },
|
||||
price: { price: { units: '100' } },
|
||||
},
|
||||
[],
|
||||
'time',
|
||||
).dirtyPrice,
|
||||
).toBeNull();
|
||||
expect(
|
||||
bondMetrics(
|
||||
{
|
||||
instrument: {
|
||||
...bond,
|
||||
riskLevel: 'RISK_LEVEL_LOW',
|
||||
floatingCouponFlag: true,
|
||||
},
|
||||
},
|
||||
['yield'],
|
||||
'time',
|
||||
),
|
||||
).toMatchObject({
|
||||
creditRating: null,
|
||||
couponType: 'COUPON_TYPE_FLOATING',
|
||||
couponYieldPercent: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -14,15 +15,15 @@ import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { BondIncomeService } from '../application/bond-income.service';
|
||||
import { BondMarketService } from '../application/bond-market.service';
|
||||
import {
|
||||
BondCandlesDto,
|
||||
BondCandlesResourceDto,
|
||||
BondCandlesQueryDto,
|
||||
BondCouponsDto,
|
||||
BondDto,
|
||||
BondMetricsDto,
|
||||
BondPositionDto,
|
||||
BondQuoteDto,
|
||||
BondCouponsResourceDto,
|
||||
BondResourceDto,
|
||||
BondMetricsResourceDto,
|
||||
BondPositionResourceDto,
|
||||
BondQuoteResourceDto,
|
||||
BondSearchQueryDto,
|
||||
BondSearchResultDto,
|
||||
BondSearchResourceDto,
|
||||
BondTickerParamsDto,
|
||||
} from './bond.dto';
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
@@ -39,68 +40,91 @@ export class BondController {
|
||||
private readonly income: BondIncomeService,
|
||||
) {}
|
||||
@Get(':uid/metrics')
|
||||
@ApiOkResponse({ type: BondMetricsDto })
|
||||
@ApiOkResponse({ type: BondMetricsResourceDto })
|
||||
metrics(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.metrics(request.user.sub, uid);
|
||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
@Get()
|
||||
@ApiOkResponse({ type: [BondSearchResultDto] })
|
||||
@ApiOkResponse({ type: BondSearchResourceDto })
|
||||
search(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: BondSearchQueryDto,
|
||||
) {
|
||||
return this.bonds.search(request.user.sub, query.query);
|
||||
return this.bonds.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
@Get('by-ticker/:ticker')
|
||||
@ApiOkResponse({ type: BondDto })
|
||||
@ApiOkResponse({ type: BondResourceDto })
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: BondTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.getByTicker(request.user.sub, params.ticker);
|
||||
return this.bonds.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
@Get(':uid')
|
||||
@ApiOkResponse({ type: BondDto })
|
||||
@ApiOkResponse({ type: BondResourceDto })
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.get(request.user.sub, uid);
|
||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
@Get(':uid/quote')
|
||||
@ApiOkResponse({ type: BondQuoteDto })
|
||||
@ApiOkResponse({ type: BondQuoteResourceDto })
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
@Get(':uid/candles')
|
||||
@ApiOkResponse({ type: BondCandlesDto })
|
||||
@ApiOkResponse({ type: BondCandlesResourceDto })
|
||||
candles(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: BondCandlesQueryDto,
|
||||
) {
|
||||
return this.market.candles(request.user.sub, uid, query.period);
|
||||
return this.market.candles(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
@Get(':uid/position')
|
||||
@ApiOkResponse({ type: BondPositionDto })
|
||||
@ApiOkResponse({ type: BondPositionResourceDto })
|
||||
position(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.position(request.user.sub, uid);
|
||||
return this.income.readPosition(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
@Get(':uid/coupons')
|
||||
@ApiOkResponse({ type: BondCouponsDto })
|
||||
@ApiOkResponse({ type: BondCouponsResourceDto })
|
||||
coupons(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.coupons(request.user.sub, uid);
|
||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
|
||||
@@ -175,3 +176,51 @@ export class BondMetricsDto {
|
||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
|
||||
export class BondPositionResourceDto {
|
||||
@ApiProperty({ type: BondPositionDto, nullable: true })
|
||||
declare data: BondPositionDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondSearchResourceDto {
|
||||
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
|
||||
declare data: BondSearchResultDto[] | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondResourceDto {
|
||||
@ApiProperty({ type: BondDto, nullable: true }) declare data: BondDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondQuoteResourceDto {
|
||||
@ApiProperty({ type: BondQuoteDto, nullable: true })
|
||||
declare data: BondQuoteDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondMetricsResourceDto {
|
||||
@ApiProperty({ type: BondMetricsDto, nullable: true })
|
||||
declare data: BondMetricsDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondCandlesResourceDto {
|
||||
@ApiProperty({ type: BondCandlesDto, nullable: true })
|
||||
declare data: BondCandlesDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondCouponsResourceDto {
|
||||
@ApiProperty({ type: BondCouponsDto, nullable: true })
|
||||
declare data: BondCouponsDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user