refactor: centralize domain values and enforce numeric lint
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user