Загрузка данных
import type { Candle as MoexChartCandle } from 'moex-chart';
import type { Candle as ApiCandle } from 'types/Candles';
const getCandleStartTime = (time: string): number => {
const normalizedTime = /(?:Z|[+-]\d{2}:\d{2})$/i.test(time) ? time : `${time}Z`;
return Math.floor(Date.parse(normalizedTime) / 1000);
};
export const candleToBar = ({ open, close, high, low, volume, begin }: ApiCandle): MoexChartCandle => ({
open,
close,
high,
low,
// TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
// В котировках volume и value значения всегда null
volume: volume || 0,
time: getCandleStartTime(begin),
});
import { Timeframes } from 'moex-chart';
// 1 = 1 минута
// 5 = 1 минут
// 10 = 10 минут
// 15 = 15 минут
// 30 = 30 минут
// 45 = 45 минут
// 60 = 1 час
// 240 = 4 часа
// 24 = 1 день
// 7 = 1 неделя
// 31 = 1 месяц
// 4 = 1 квартал
const MOEX_CHART_TIMEFRAMES_INTO_INTERVALS: Record<string, string> = {
[Timeframes['1m']]: '1',
[Timeframes['5m']]: '1',
[Timeframes['10m']]: '1',
[Timeframes['15m']]: '1',
[Timeframes['30m']]: '1',
[Timeframes['45m']]: '1',
[Timeframes['1h']]: '60',
[Timeframes['4h']]: '60',
[Timeframes['1d']]: '24',
[Timeframes['1w']]: '7',
[Timeframes['1M']]: '31',
};
const MOEX_CHART_TIMEFRAMES_TO_ISS_POSSIBLE_TIMEFRAMES: Record<string, Timeframes> = {
[Timeframes['1m']]: Timeframes['1m'],
[Timeframes['5m']]: Timeframes['1m'],
[Timeframes['10m']]: Timeframes['1m'],
[Timeframes['15m']]: Timeframes['1m'],
[Timeframes['30m']]: Timeframes['1m'],
[Timeframes['45m']]: Timeframes['1m'],
[Timeframes['1h']]: Timeframes['1h'],
[Timeframes['4h']]: Timeframes['1h'],
[Timeframes['1d']]: Timeframes['1d'],
[Timeframes['1w']]: Timeframes['1w'],
[Timeframes['1M']]: Timeframes['1M'],
};
const INTERVALS: Record<string, string> = {
'1': '1',
'5': '5',
'10': '10',
'15': '15',
'30': '30',
'45': '45',
'60': '60',
'240': '240',
'1D': '24',
'7D': '7',
'1M': '31',
'3M': '4',
};
export const chartToReqTimeConverter = (value: string) => INTERVALS[value];
export const moexChartTimeConverter = (timeframe: string) => MOEX_CHART_TIMEFRAMES_INTO_INTERVALS[timeframe];
export const moexChartToIssTimeframe = (timeframe: string) =>
MOEX_CHART_TIMEFRAMES_TO_ISS_POSSIBLE_TIMEFRAMES[timeframe];
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { parseTimeframe } from 'moex-chart';
import {
moexChartTimeConverter,
moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
interface HistoryRequestState {
untilTime?: number;
request: Promise<Candle[] | null> | null;
}
interface RealtimeConvolutionState {
alignmentOffset: number;
timeframeSeconds: number;
sourceCandles: Candle[];
lastSourceCandle?: Candle;
}
interface TimeframeConvolutionResult {
candles: Candle[];
alignmentOffset: number;
timeframeSeconds: number;
latestSourceCandles: Candle[];
}
function getRequestSymbol(symbolRaw?: string): string | undefined {
const symbol = String(symbolRaw ?? '').trim();
if (!symbol || symbol === DEFAULT_SYMBOL) {
return undefined;
}
return symbol;
}
function getConvolutionKey(symbol: string, timeframe: Timeframes): string {
return `${symbol}:${timeframe}`;
}
function getTimeframeSeconds(timeframe: Timeframes): number {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}
function getPositiveModulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
function getAlignmentOffset(data: Candle[], sourceTimeframeSeconds: number, timeframeSeconds: number): number {
const firstCandle = data[0];
if (!firstCandle) {
return 0;
}
let sessionStart = firstCandle;
let largestGap = 0;
for (let index = 1; index < data.length; index += 1) {
const previousCandle = data[index - 1];
const currentCandle = data[index];
if (!previousCandle || !currentCandle) {
continue;
}
const gap = currentCandle.time - previousCandle.time;
if (gap > sourceTimeframeSeconds && gap > largestGap) {
largestGap = gap;
sessionStart = currentCandle;
}
}
return getPositiveModulo(sessionStart.time, timeframeSeconds);
}
function getBucketStart(time: number, timeframeSeconds: number, alignmentOffset: number): number {
return Math.floor((time - alignmentOffset) / timeframeSeconds) * timeframeSeconds + alignmentOffset;
}
function mergeCandles(aggregatedCandle: Candle, currentCandle: Candle): Candle {
return {
...aggregatedCandle,
high: Math.max(aggregatedCandle.high, currentCandle.high),
low: Math.min(aggregatedCandle.low, currentCandle.low),
close: currentCandle.close,
volume: (aggregatedCandle.volume ?? 0) + (currentCandle.volume ?? 0),
};
}
function aggregateCandles(data: Candle[], timeframeSeconds: number, alignmentOffset: number): Candle[] {
const result: Candle[] = [];
let currentBucketStart: number | null = null;
let aggregatedCandle: Candle | null = null;
data.forEach((candle) => {
const bucketStart = getBucketStart(candle.time, timeframeSeconds, alignmentOffset);
if (currentBucketStart !== bucketStart) {
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
currentBucketStart = bucketStart;
aggregatedCandle = {
...candle,
time: bucketStart,
};
return;
}
if (aggregatedCandle) {
aggregatedCandle = mergeCandles(aggregatedCandle, candle);
}
});
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
return result;
}
function getLatestSourceCandles(
data: Candle[],
timeframeSeconds: number,
alignmentOffset: number,
): Candle[] {
const lastCandle = data[data.length - 1];
if (!lastCandle) {
return [];
}
const latestBucketStart = getBucketStart(lastCandle.time, timeframeSeconds, alignmentOffset);
return data.filter(
(candle) => getBucketStart(candle.time, timeframeSeconds, alignmentOffset) === latestBucketStart,
);
}
function timeframeConvolution(data: Candle[], requestedTimeframe: Timeframes): TimeframeConvolutionResult {
const issTimeframe = moexChartToIssTimeframe(requestedTimeframe);
if (issTimeframe === requestedTimeframe) {
return {
candles: data,
alignmentOffset: 0,
timeframeSeconds: 0,
latestSourceCandles: [],
};
}
const sortedData = [...data].sort((firstCandle, secondCandle) => firstCandle.time - secondCandle.time);
const sourceTimeframeSeconds = getTimeframeSeconds(issTimeframe);
const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
const alignmentOffset = getAlignmentOffset(sortedData, sourceTimeframeSeconds, timeframeSeconds);
return {
candles: aggregateCandles(sortedData, timeframeSeconds, alignmentOffset),
alignmentOffset,
timeframeSeconds,
latestSourceCandles: getLatestSourceCandles(sortedData, timeframeSeconds, alignmentOffset),
};
}
function areCandlesEqual(firstCandle: Candle | undefined, secondCandle: Candle): boolean {
if (!firstCandle) {
return false;
}
return (
firstCandle.time === secondCandle.time &&
firstCandle.open === secondCandle.open &&
firstCandle.high === secondCandle.high &&
firstCandle.low === secondCandle.low &&
firstCandle.close === secondCandle.close &&
firstCandle.volume === secondCandle.volume
);
}
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private realtimeConvolutionStates = new Map<string, RealtimeConvolutionState>();
private realtimeTimer: ReturnType<typeof setInterval> | null = null;
private historyRequests = new Map<string, HistoryRequestState>();
public getDataSource =
(indicativeData?: ChartIndicativeData, cb?: (timeframe: Timeframes) => void) =>
async (timeframe: Timeframes, symbolId: string, until?: Candle): Promise<Candle[] | null> => {
const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return null;
}
const historyRequestKey = `${symbol}:${timeframe}`;
const historyRequestState = this.historyRequests.get(historyRequestKey);
if (historyRequestState && historyRequestState.untilTime === until?.time) {
if (historyRequestState.request) {
return historyRequestState.request;
}
if (until) {
return null;
}
}
cb?.(timeframe);
const historyRequest = this.requestHistoryData({
timeframe,
symbol,
until,
indicativeData,
});
this.historyRequests.set(historyRequestKey, {
untilTime: until?.time,
request: historyRequest,
});
try {
const data = await historyRequest;
return data;
} finally {
if (this.historyRequests.get(historyRequestKey)?.request === historyRequest) {
if (until) {
this.historyRequests.set(historyRequestKey, {
untilTime: until.time,
request: null,
});
} else {
this.historyRequests.delete(historyRequestKey);
}
}
}
};
public startRealtime({
getSymbols,
getTimeframe,
update,
periodMs = 5000,
indicativeData,
}: {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
update: (symbolId: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData;
}): () => void {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = setInterval(() => {
const timeframe = getTimeframe();
const symbolIds = getSymbols();
Promise.all(
symbolIds.map(async (symbolId) => {
const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return;
}
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (!data) {
return;
}
const issTimeframe = moexChartToIssTimeframe(timeframe);
if (issTimeframe === timeframe) {
update(symbol, data);
return;
}
this.realtimeConvolution(symbol, timeframe, data, (candle) => {
update(symbol, candle);
});
}),
);
}, periodMs);
return () => {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
};
}
private async requestHistoryData({
timeframe,
symbol,
until,
indicativeData,
}: {
timeframe: Timeframes;
symbol: string;
until?: Candle;
indicativeData?: ChartIndicativeData;
}): Promise<Candle[] | null> {
const interval = moexChartTimeConverter(timeframe);
const date = until?.time || Math.round(Date.now() / 1000);
const data = await requestBars({
currencyPair: symbol.replaceAll(':', '.'),
interval,
periodParams: {
firstDataRequest: true,
to: date,
from: Date.now(),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
if (data.length === 0) {
return null;
}
const issTimeframe = moexChartToIssTimeframe(timeframe);
const convolutionKey = getConvolutionKey(symbol, timeframe);
if (issTimeframe === timeframe) {
this.realtimeConvolutionStates.delete(convolutionKey);
return data;
}
const {
candles,
alignmentOffset,
timeframeSeconds,
latestSourceCandles,
} = timeframeConvolution(data, timeframe);
this.realtimeConvolutionStates.set(convolutionKey, {
alignmentOffset,
timeframeSeconds,
sourceCandles: latestSourceCandles,
lastSourceCandle: data[data.length - 1],
});
return candles;
}
private realtimeConvolution(
symbol: string,
timeframe: Timeframes,
data: Candle,
update: (candle: Candle) => void,
): void {
const convolutionKey = getConvolutionKey(symbol, timeframe);
let state = this.realtimeConvolutionStates.get(convolutionKey);
if (!state) {
const timeframeSeconds = getTimeframeSeconds(timeframe);
state = {
alignmentOffset: getPositiveModulo(data.time, timeframeSeconds),
timeframeSeconds,
sourceCandles: [],
};
this.realtimeConvolutionStates.set(convolutionKey, state);
}
if (areCandlesEqual(state.lastSourceCandle, data)) {
return;
}
const currentBucketStart = getBucketStart(
data.time,
state.timeframeSeconds,
state.alignmentOffset,
);
const previousSourceCandle = state.sourceCandles[state.sourceCandles.length - 1];
if (
previousSourceCandle &&
getBucketStart(
previousSourceCandle.time,
state.timeframeSeconds,
state.alignmentOffset,
) !== currentBucketStart
) {
state.sourceCandles = [data];
} else {
const candleIndex = state.sourceCandles.findIndex((candle) => candle.time === data.time);
if (candleIndex === -1) {
state.sourceCandles.push(data);
} else {
state.sourceCandles[candleIndex] = data;
}
}
state.sourceCandles.sort((firstCandle, secondCandle) => firstCandle.time - secondCandle.time);
state.lastSourceCandle = data;
const firstCandle = state.sourceCandles[0];
if (!firstCandle) {
return;
}
const candle = state.sourceCandles.reduce<Candle>(
(aggregatedCandle, currentCandle) => mergeCandles(aggregatedCandle, currentCandle),
{
...firstCandle,
time: currentBucketStart,
},
);
update(candle);
}
}
export { DataSourceProvider };
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';
import { ChartIndicativeData } from './types';
import { isIndicativeTicker } from './utils/isIndicativeTicker';
import { transformKeyToLowerCase } from './utils/transformKeyToLowerCase';
import type { Candle } from 'moex-chart';
dayjs.extend(utc);
export interface PeriodParams {
from: number;
to: number;
countBack: number;
firstDataRequest: boolean;
}
export interface HistoryMetadata {
noData: boolean;
}
export type HistoryCallback = (candles: Candle[], metadata: HistoryMetadata) => void;
export type SubscribeBarsCallback = (candle: Candle) => void;
interface RequestBarsArgs {
currencyPair: string;
interval: string;
periodParams: PeriodParams;
onHistoryCallback?: HistoryCallback;
ticker?: string;
indicativeData?: ChartIndicativeData;
}
interface RequestRealtimeBarsArgs {
currencyPair: string;
interval: string;
ticker?: string;
indicativeData?: ChartIndicativeData;
onRealtimeCallback?: SubscribeBarsCallback;
}
export type CustomBarsResolver = (args: {
ticker?: string;
currencyPair: string;
periodParams: PeriodParams;
interval: string;
}) => Promise<Candle[]>;
const barsResolvers = new Map<string, CustomBarsResolver>();
export const registerBarsResolver = (boardKey: string, resolver: CustomBarsResolver) => {
barsResolvers.set(boardKey, resolver);
};
const getResolverKey = (ticker?: string) => {
const [source, board] = ticker?.split(/[:.]/) ?? [];
if (!source || !board) {
return '';
}
return `${source}:${board}`;
};
const getBarsResolver = (ticker?: string) => barsResolvers.get(getResolverKey(ticker));
export async function requestBars({
currencyPair,
interval,
periodParams,
onHistoryCallback,
ticker,
indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
const customResolver = getBarsResolver(ticker);
if (customResolver) {
try {
const customBars = await customResolver({
ticker,
currencyPair,
periodParams,
interval,
});
const olderBars = customBars.filter((bar) => bar.time < periodParams.to);
onHistoryCallback?.(olderBars, {
noData: olderBars.length === 0,
});
return olderBars;
} catch (e) {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
}
const date = new Date(periodParams.to * 1000);
const year = date.getUTCFullYear();
const month = `0${date.getUTCMonth() + 1}`.slice(-2);
const day = `0${date.getUTCDate()}`.slice(-2);
const hours = `0${date.getUTCHours()}`.slice(-2);
const minutes = `0${date.getUTCMinutes()}`.slice(-2);
const seconds = `0${date.getUTCSeconds()}`.slice(-2);
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий инструмент совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument =
(indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
const lowerCaseKey = transformKeyToLowerCase(currencyPair);
if (!lowerCaseKey) {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
try {
const { data } = await indicativeQuotesController.getCandles({
count: periodParams.countBack,
key: lowerCaseKey,
date: dateStr,
interval,
});
const candles = data.indicativeCandles.reverse().map(candleToBar);
onHistoryCallback?.(candles, {
noData: candles.length === 0,
});
return candles;
} catch {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
}
const dateStr = `${year}-${month}-${day}%20${hours}:${minutes}:${seconds}`;
try {
const { data } = await api.getBars({
currencyPair,
date: dateStr,
interval,
count: periodParams.countBack,
ticker,
});
const bars = data.reverse().map(candleToBar);
onHistoryCallback?.(bars, {
noData: bars.length === 0,
});
return bars;
} catch {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
}
export async function requestRealtimeBars({
currencyPair,
interval,
ticker,
onRealtimeCallback,
indicativeData,
}: RequestRealtimeBarsArgs): Promise<Candle | undefined> {
if (getBarsResolver(ticker)) {
return;
}
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий инструмент совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument =
(indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
const lowerCaseKey = transformKeyToLowerCase(currencyPair);
if (!lowerCaseKey) {
return undefined;
}
try {
const { data } = await indicativeQuotesController.getCandles({
count: 1,
key: lowerCaseKey,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DDTHH:mm:ss'),
interval,
});
if (data.indicativeCandles.length === 0) {
return undefined;
}
const sortedData = [...data.indicativeCandles].sort(
(first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
);
const firstCandle = sortedData[0];
if (!firstCandle) {
return undefined;
}
const bar = candleToBar(firstCandle);
onRealtimeCallback?.(bar);
return bar;
} catch (error) {
console.error('error from requestRealTimeBars indicativeQuotesController: ', error);
return undefined;
}
}
try {
const { data } = await api.getBars({
currencyPair,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DD%20HH:mm:ss'),
interval,
count: 1,
ticker,
});
if (data.length === 0) {
return undefined;
}
const sortedData = [...data].sort(
(first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
);
const firstCandle = sortedData[0];
if (!firstCandle) {
return undefined;
}
const bar = candleToBar(firstCandle);
onRealtimeCallback?.(bar);
return bar;
} catch (error) {
console.error('error from requestRealTimeBars: ', error);
return undefined;
}
}