refactor: centralize domain values and enforce numeric lint

This commit is contained in:
2026-09-13 14:37:39 +03:00
parent 8ba005346d
commit 8637ad2f34
44 changed files with 543 additions and 174 deletions
+6 -3
View File
@@ -1,5 +1,8 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
const FALLBACK_PAGE_SIZE = 100;
const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
@@ -121,8 +124,8 @@ export class MoexClient {
if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) {
if (current.length < 100) return rows;
start += 100;
if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += FALLBACK_PAGE_SIZE;
continue;
}
const cursor = parseRows(cursorBlock)[0];
@@ -145,7 +148,7 @@ export class MoexClient {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
if (response.status === 404) return null;
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json();
if (!body || typeof body !== 'object')
+15 -9
View File
@@ -2,6 +2,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
import { LRUCache } from 'lru-cache';
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';
@@ -18,7 +24,7 @@ export class NcrRatingsClient {
private readonly cache = new LRUCache<
string,
Promise<Map<string, NcrRating>>
>({ max: 1, ttl: 6 * 60 * 60_000 });
>({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
async rating(isin: string): Promise<NcrRating | null> {
const key = isin.trim().toUpperCase();
@@ -38,7 +44,7 @@ export class NcrRatingsClient {
private async registry(): Promise<Map<string, NcrRating>> {
const response = await fetch(registryUrl, {
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) throw new Error('NCR unavailable');
const $ = load(await response.text());
@@ -49,18 +55,18 @@ export class NcrRatingsClient {
.get();
if (
table.length !== 1 ||
headings.length !== 6 ||
headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' ||
headings[3] !== 'ISIN' ||
headings[5] !== 'Дата'
headings[ISIN_COLUMN] !== 'ISIN' ||
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 !== 6) throw new Error('NCR row changed');
const isin = cells.eq(3).text().trim().toUpperCase();
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(2).text().replace(/\s+/g, ' ').trim();
@@ -73,9 +79,9 @@ export class NcrRatingsClient {
)
throw new Error('NCR rating changed');
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(5).text().trim(),
cells.eq(DATE_COLUMN).text().trim(),
);
const href = cells.eq(3).find('a').attr('href');
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]}`;
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccount } from './tbank.client';
const accountTypes = {
@@ -51,7 +52,7 @@ export function accessibleTbankAccount(
function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * 1000);
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10);
}
+25 -8
View File
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import {
ChannelCredentials,
Client,
@@ -28,6 +29,13 @@ import type {
TbankShare,
} from './tbank-market.types';
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000;
type Unary<Request, Response> = (
@@ -134,7 +142,7 @@ class RateLimiter {
private drain(): void {
const now = Date.now();
if (now - this.windowStartedAt >= 1000) {
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now;
this.starts = 0;
}
@@ -145,7 +153,7 @@ class RateLimiter {
if (this.waiting.length && this.starts === this.perSecond) {
setTimeout(
() => this.drain(),
Math.max(1, 1000 - (now - this.windowStartedAt)),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
);
}
}
@@ -422,8 +430,12 @@ export class TbankClient {
'getCandles',
{
instrumentId: uid,
from: { seconds: String(Math.floor(from.valueOf() / 1000)) },
to: { seconds: String(Math.floor(to.valueOf() / 1000)) },
from: {
seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)),
},
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY',
limit: 2400,
},
@@ -456,10 +468,10 @@ export class TbankClient {
request: object,
): Promise<Response> {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? 3 : 2;
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now();
const queueFull = this.queue.size >= 50;
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
@@ -492,7 +504,12 @@ export class TbankClient {
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000),
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue;
}
@@ -537,5 +554,5 @@ export class TbankClient {
}
function utcSeconds(date: string): number {
return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
}
+4 -1
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const KEY_SIZE_BYTES = 32;
const VERSION = 1;
const NONCE_SIZE = 12;
const TAG_SIZE = 16;
@@ -9,7 +11,8 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64');
if (decoded.length !== 32) throw new Error('Invalid token encryption key');
if (decoded.length !== KEY_SIZE_BYTES)
throw new Error('Invalid token encryption key');
return new TbankCrypto(decoded);
}