Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|
import { load } from 'cheerio';
|
|
import { LRUCache } from 'lru-cache';
|
|
|
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
|
|
|
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
|
|
|
const EXPECTED_REGISTRY_TABLES = 1;
|
|
const RATING_COLUMN = 2;
|
|
|
|
const REGISTRY_CACHE_TTL_MS = 21_600_000;
|
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
const COLUMN_COUNT = 6;
|
|
const ISIN_COLUMN = 3;
|
|
const DATE_COLUMN = 5;
|
|
|
|
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
|
export type NcrRating = {
|
|
agency: 'NCR';
|
|
date: string;
|
|
observedAt: string;
|
|
sourceUrl: string;
|
|
status: 'active' | 'withdrawn';
|
|
value: null | string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class NcrRatingsClient {
|
|
// Public registry contains no user data. Share one download across all issues.
|
|
private readonly cache = new LRUCache<
|
|
string,
|
|
Promise<Map<string, NcrRating>>
|
|
>({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
|
|
|
|
async rating(isin: string): Promise<NcrRating | null> {
|
|
const key = isin.trim().toUpperCase();
|
|
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
|
|
let pending = this.cache.get('registry');
|
|
if (!pending) {
|
|
pending = this.registry().catch(() => {
|
|
// Bound outage traffic too; never present a failed download as no rating.
|
|
if (this.cache.get('registry') === pending) {
|
|
this.cache.set('registry', pending, { ttl: 60_000 });
|
|
}
|
|
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
|
|
});
|
|
this.cache.set('registry', pending);
|
|
}
|
|
return (await pending).get(key) ?? null;
|
|
}
|
|
|
|
private async registry(): Promise<Map<string, NcrRating>> {
|
|
const response = await fetch(registryUrl, {
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
if (!response.ok) throw new Error('NCR unavailable');
|
|
const $ = load(await response.text());
|
|
const table = $('#issues-table');
|
|
const headings = table
|
|
.find('thead th')
|
|
.map((_, cell) => $(cell).text().trim())
|
|
.get();
|
|
if (
|
|
table.length !== EXPECTED_REGISTRY_TABLES ||
|
|
headings.length !== COLUMN_COUNT ||
|
|
headings[2] !== 'Рейтинг' ||
|
|
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
|
headings[ISIN_COLUMN] !== 'ISIN' ||
|
|
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
|
|
headings[DATE_COLUMN] !== 'Дата'
|
|
) {
|
|
throw new Error('NCR registry changed');
|
|
}
|
|
const observedAt = new Date().toISOString();
|
|
const result = new Map<string, NcrRating>();
|
|
table.find('tbody tr').each((_, row) => {
|
|
const cells = $(row).find('td');
|
|
if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
|
|
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
|
|
// Unassigned issues cannot safely be matched to a held instrument.
|
|
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
|
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
|
|
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
|
if (
|
|
!withdrawn &&
|
|
!/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
|
|
raw,
|
|
)
|
|
) {
|
|
throw new Error('NCR rating changed');
|
|
}
|
|
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
|
cells.eq(DATE_COLUMN).text().trim(),
|
|
);
|
|
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
|
|
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
|
|
throw new Error('NCR identity missing');
|
|
}
|
|
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
|
if (
|
|
!Number.isFinite(Date.parse(date)) ||
|
|
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
|
|
) {
|
|
throw new Error('NCR date invalid');
|
|
}
|
|
const rating: NcrRating = {
|
|
agency: 'NCR',
|
|
value: withdrawn ? null : raw,
|
|
status: withdrawn ? 'withdrawn' : 'active',
|
|
date,
|
|
sourceUrl: `https://ratings.ru${href}`,
|
|
observedAt,
|
|
};
|
|
const previous = result.get(isin);
|
|
if (
|
|
previous &&
|
|
previous.date === date &&
|
|
(previous.value !== rating.value || previous.status !== rating.status)
|
|
) {
|
|
throw new Error('NCR rating ambiguous');
|
|
}
|
|
if (!previous || previous.date < date) result.set(isin, rating);
|
|
});
|
|
if (!result.size) throw new Error('NCR registry empty');
|
|
return result;
|
|
}
|
|
}
|