feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
This commit was merged in pull request #200.
This commit is contained in:
@@ -10,6 +10,13 @@ const EMPTY_PAGE_SIZE = 0;
|
||||
const MIN_CURSOR_VALUE = 0;
|
||||
|
||||
const FALLBACK_PAGE_SIZE = 100;
|
||||
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
|
||||
const ISO_DATE_LENGTH = 10;
|
||||
const ZERO_CLOSE = 0;
|
||||
const ISO_DATE_START = 0;
|
||||
const LAST_HISTORY_ROW = -1;
|
||||
|
||||
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const baseUrl = 'https://iss.moex.com/iss';
|
||||
const timeoutMs = 10_000;
|
||||
@@ -28,9 +35,64 @@ export type MoexBondYield = {
|
||||
tradeAt: string;
|
||||
value: string;
|
||||
};
|
||||
export type MoexIndexClose = { close: string; tradeDate: string };
|
||||
|
||||
@Injectable()
|
||||
export class MoexClient {
|
||||
async indexHistory(
|
||||
secid: 'IMOEX' | 'MCFTRR',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<MoexIndexClose[]> {
|
||||
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
|
||||
const rows: MoexIndexClose[] = [];
|
||||
let start = FIRST_PAGE_OFFSET;
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({
|
||||
from,
|
||||
till,
|
||||
'start': String(start),
|
||||
'iss.meta': 'off',
|
||||
'history.columns': 'TRADEDATE,CLOSE',
|
||||
});
|
||||
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
const block = response.history;
|
||||
if (
|
||||
!block || !Array.isArray(block.columns) ||
|
||||
block.columns.length !== INDEX_COLUMNS.length ||
|
||||
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
|
||||
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
const current = parseRows(block);
|
||||
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
|
||||
for (const row of current) {
|
||||
const tradeDate = text(row.tradedate);
|
||||
const close = numberText(row.close);
|
||||
if (
|
||||
!tradeDate || !isoDayPattern.test(tradeDate) ||
|
||||
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
|
||||
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
|
||||
!close || Number(close) <= ZERO_CLOSE ||
|
||||
(previousDate !== undefined && tradeDate <= previousDate)
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
rows.push({ tradeDate, close });
|
||||
previousDate = tradeDate;
|
||||
}
|
||||
const cursor = parseRows(response['history.cursor']);
|
||||
const index = integer(cursor[0]?.index);
|
||||
const total = integer(cursor[0]?.total);
|
||||
const pageSize = integer(cursor[0]?.pagesize);
|
||||
if (
|
||||
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
|
||||
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
|
||||
current.length !== Math.min(pageSize, total - start)
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
if (index + pageSize >= total) return rows;
|
||||
start = index + pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
async bondYield(
|
||||
secid: string,
|
||||
isin: string,
|
||||
|
||||
Reference in New Issue
Block a user