refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
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';
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
@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, 30);
|
||||
if (!bonds.length) return [];
|
||||
// Batch prices and share metadata with detail sections. Resolve readable
|
||||
// aliases only when an issue is opened, not for every search result.
|
||||
const [details, prices] = await Promise.all([
|
||||
Promise.all(
|
||||
bonds.map(
|
||||
async (bond) =>
|
||||
(await optional(
|
||||
() =>
|
||||
this.bond(userId, connection, bond.uid)
|
||||
.then(toBond)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof NotFoundException)
|
||||
return toBond(bond);
|
||||
throw error;
|
||||
}),
|
||||
'details',
|
||||
[],
|
||||
)) ?? toBond(bond),
|
||||
),
|
||||
),
|
||||
optional(
|
||||
() =>
|
||||
this.tbank.getLastPrices(
|
||||
token,
|
||||
bonds.map((bond) => bond.uid),
|
||||
),
|
||||
'prices',
|
||||
[],
|
||||
),
|
||||
]);
|
||||
return details.map((detail, index) => {
|
||||
const short = toBond(bonds[index]);
|
||||
const last = prices?.find(
|
||||
(price) => price.instrumentUid === detail.instrumentUid,
|
||||
);
|
||||
return {
|
||||
...detail,
|
||||
classCode: detail.classCode ?? short.classCode,
|
||||
isin: detail.isin ?? short.isin,
|
||||
apiTradeAvailable: short.apiTradeAvailable,
|
||||
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
|
||||
priceAt: timestamp(last?.time),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
get(userId: string, uid: string): Promise<BondDto> {
|
||||
return this.cached(userId, `bond:${uid}`, async (connection) => {
|
||||
const result = toBond(await this.bond(userId, connection, uid));
|
||||
if (
|
||||
!result.ticker ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
|
||||
)
|
||||
return result;
|
||||
try {
|
||||
const match = await this.tickerMatch(userId, result.ticker);
|
||||
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
|
||||
} catch (error) {
|
||||
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
|
||||
if (
|
||||
!(error instanceof NotFoundException) &&
|
||||
!(error instanceof ConflictException) &&
|
||||
!(error instanceof ServiceUnavailableException)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async getByTicker(userId: string, ticker: string): Promise<BondDto> {
|
||||
const match = await this.tickerMatch(userId, ticker);
|
||||
return {
|
||||
...(await this.get(userId, match.uid)),
|
||||
slug: ticker.trim().toUpperCase(),
|
||||
};
|
||||
}
|
||||
private tickerMatch(userId: string, ticker: string): Promise<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(
|
||||
userId: string,
|
||||
connection: TbankConnectionContext,
|
||||
uid: string,
|
||||
): Promise<IdentifiedBond> {
|
||||
return this.cachedForConnection(
|
||||
userId,
|
||||
connection,
|
||||
`metadata:${uid}`,
|
||||
async () => {
|
||||
const bond = await this.tbank.getBond(connection.token, uid);
|
||||
if (!bond || bond.uid !== uid) throw new NotFoundException();
|
||||
return { ...bond, uid };
|
||||
},
|
||||
);
|
||||
}
|
||||
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 {
|
||||
return {
|
||||
instrumentUid: bond.uid,
|
||||
slug: bond.uid,
|
||||
name: bond.name?.trim() || bond.ticker || bond.uid,
|
||||
ticker: bond.ticker?.trim() || null,
|
||||
isin: bond.isin?.trim() || null,
|
||||
classCode: bond.classCode?.trim() || null,
|
||||
realExchange: bond.realExchange || null,
|
||||
apiTradeAvailable: bond.apiTradeAvailableFlag ?? null,
|
||||
logoName: bond.brand?.logoName?.trim() || null,
|
||||
currency: bond.currency?.toUpperCase() || null,
|
||||
bondType: bond.bondType || null,
|
||||
sector: bond.sector || null,
|
||||
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
|
||||
historyFrom: timestamp(bond.first_1dayCandleDate),
|
||||
nominal: money(bond.nominal),
|
||||
initialNominal: money(bond.initialNominal),
|
||||
aci: money(bond.aciValue),
|
||||
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
|
||||
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
|
||||
floatingCoupon: bond.floatingCouponFlag ?? null,
|
||||
perpetual: bond.perpetualFlag ?? null,
|
||||
amortization: bond.amortizationFlag ?? null,
|
||||
subordinated: bond.subordinatedFlag ?? null,
|
||||
riskLevel: bond.riskLevel || null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user