Загрузка данных
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import type { Timeframes as TimeframesType } from 'moex-chart';
type DataSourceProvideModule = typeof import('@widgets/Chart/components/MoexChart/dataSourceProvide');
const mockRequestBars = jest.fn();
const mockRequestRealtimeBars = jest.fn();
const mockMoexChartTimeConverter = jest.fn();
const mockParseTimeframe = jest.fn();
const mockGetStartTime = jest.fn();
const mockMoexChartToIssTimeframe = jest.fn();
const mockGetISSOddTimeframePart = jest.fn();
// Mock the dependencies
jest.mock('moex-chart', () => ({
Timeframes: {
'10s': '10s',
'1m': '1m',
'5m': '5m',
},
parseTimeframe: mockParseTimeframe,
getStartTime: mockGetStartTime,
}));
jest.mock('@utils/chartToReqTimeConverter', () => ({
moexChartTimeConverter: mockMoexChartTimeConverter,
moexChartToIssTimeframe: mockMoexChartToIssTimeframe,
getISSOddTimeframePart: mockGetISSOddTimeframePart,
}));
jest.mock('../requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
jest.mock('@widgets/Chart/requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
const { DataSourceProvider } = jest.requireActual(
'@widgets/Chart/components/MoexChart/dataSourceProvide',
) as DataSourceProvideModule;
const Timeframes = {
'10s': '10s' as TimeframesType,
'1m': '1m' as TimeframesType,
'5m': '5m' as TimeframesType,
};
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
describe('DataSourceProvider', () => {
const mockBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
const baseTime = Date.parse('2026-05-19T10:00:00Z');
const createBar = (minute: number, overrides: Partial<typeof mockBar> = {}) => ({
time: baseTime + minute * 60_000,
open: 100 + minute,
close: 101 + minute,
high: 102 + minute,
low: 99 - minute,
volume: minute + 1,
...overrides,
});
const configureFiveMinuteConvolution = (): void => {
mockMoexChartToIssTimeframe.mockReturnValue(Timeframes['1m']);
mockParseTimeframe.mockImplementation((timeframe: TimeframesType) =>
timeframe === Timeframes['5m']
? {
candleWidth: 5,
dayjsUnit: 'minute',
}
: {
candleWidth: 1,
dayjsUnit: 'minute',
},
);
mockGetStartTime.mockImplementation((timeframe: TimeframesType, time: number) => {
const intervalMs = timeframe === Timeframes['5m'] ? 5 * 60_000 : 60_000;
return (Math.floor(time / intervalMs) * intervalMs) / 1000;
});
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-19T10:00:00Z'));
mockMoexChartTimeConverter.mockReturnValue('1');
mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) => timeframe);
mockGetISSOddTimeframePart.mockReturnValue({
value: 0,
unit: 'minute',
});
mockParseTimeframe.mockReturnValue({
candleWidth: 1,
dayjsUnit: 'minute',
});
mockGetStartTime.mockImplementation((_timeframe: TimeframesType, time: number) => Math.floor(time / 1000));
});
afterEach(() => {
jest.useRealTimers();
});
it('should request chart history data with converted timeframe', async () => {
// Arrange
const mockTimeframeCallback = jest.fn();
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockTimeframeCallback).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockMoexChartTimeConverter).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockRequestBars).toHaveBeenCalledWith({
currencyPair: 'MOEX.SBER',
interval: '1',
periodParams: {
firstDataRequest: true,
to: Math.round(Date.now() / 1000),
from: Date.now(),
countBack: 2000,
},
ticker: 'MOEX:SBER',
indicativeData: undefined,
});
expect(result).toEqual([mockBar]);
});
it('should not request chart history data for default symbol', async () => {
// Arrange
const mockTimeframeCallback = jest.fn();
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
// Act
const result = await dataSource(Timeframes['1m'], DEFAULT_SYMBOL);
// Assert
expect(result).toBeNull();
expect(mockRequestBars).not.toHaveBeenCalled();
});
it('should not request chart history data for empty symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['1m'], ' ');
// Assert
expect(result).toBeNull();
expect(mockRequestBars).not.toHaveBeenCalled();
});
it('should request chart history data with indicative data', async () => {
// Arrange
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(indicativeData);
// Act
await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should use until time when it is provided', async () => {
// Arrange
mockMoexChartTimeConverter.mockReturnValue('5');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = { time: 111 } as NonNullable<Parameters<typeof dataSource>[2]>;
// Act
await dataSource(Timeframes['5m'], 'MOEX:GAZP', until);
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
periodParams: expect.objectContaining({
to: 111,
}),
}),
);
});
it('should return null when history data is empty', async () => {
// Arrange
mockRequestBars.mockResolvedValue([]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(result).toBeNull();
});
it('should request realtime data and update normalized symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const localMockedBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: Math.floor(Math.random() * 1000),
};
mockRequestRealtimeBars.mockImplementation(() => localMockedBar);
provider.startRealtime({
getSymbols: () => [' moex:sber '],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
currencyPair: 'moex.sber',
interval: '1',
ticker: 'moex:sber',
indicativeData: undefined,
});
expect(mockUpdate).toHaveBeenCalledWith('moex:sber', localMockedBar);
});
it('should not request realtime data for default symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => [DEFAULT_SYMBOL],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data for default symbol with surrounding spaces', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => [` ${DEFAULT_SYMBOL} `],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should request realtime data with indicative data', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
indicativeData,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should not call update when realtime data is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
mockRequestRealtimeBars.mockResolvedValue(undefined);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data when symbols list is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => [],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should clear realtime timer on unsubscribe', async () => {
// Arrange
const provider = new DataSourceProvider();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
unsubscribe();
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should convolve history data when ISS timeframe differs', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = Array.from({ length: 11 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 110,
high: 111,
low: 90,
volume: 40,
},
]);
});
it('should convolve incomplete history candle group', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = Array.from({ length: 8 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 108,
high: 109,
low: 92,
volume: 21,
},
]);
});
it('should stop candle group when candle is outside current interval', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = [
createBar(0),
createBar(1),
createBar(2),
createBar(3),
createBar(4),
createBar(5),
createBar(11),
createBar(12),
];
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 112,
high: 113,
low: 88,
volume: 18,
},
]);
});
it('should return empty data when convolution start index is not found', async () => {
// Arrange
configureFiveMinuteConvolution();
mockGetStartTime.mockReturnValue(1);
mockRequestBars.mockResolvedValue([createBar(0), createBar(1)]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([]);
});
it('should append, replace and reset realtime candles during convolution', async () => {
// Arrange
configureFiveMinuteConvolution();
mockRequestBars.mockResolvedValue(Array.from({ length: 11 }, (_, minute) => createBar(minute)));
const provider = new DataSourceProvider();
await provider.getDataSource()(Timeframes['5m'], 'MOEX:SBER');
const firstCandle = {
time: baseTime + 5 * 60_000,
open: 100,
high: 103,
low: 99,
close: 102,
volume: 10,
};
const secondCandle = {
time: baseTime + 6 * 60_000,
open: 102,
high: 106,
low: 98,
close: 105,
volume: 20,
};
const updatedSecondCandle = {
...secondCandle,
high: 107,
low: 97,
close: 106,
volume: 25,
};
const nextTimeframeCandle = {
time: baseTime + 10 * 60_000,
open: 106,
high: 108,
low: 105,
close: 107,
volume: 30,
};
mockRequestRealtimeBars
.mockResolvedValueOnce(firstCandle)
.mockResolvedValueOnce(secondCandle)
.mockResolvedValueOnce(updatedSecondCandle)
.mockResolvedValueOnce(nextTimeframeCandle);
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['5m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockUpdate).toHaveBeenNthCalledWith(1, 'MOEX:SBER', firstCandle);
expect(mockUpdate).toHaveBeenNthCalledWith(2, 'MOEX:SBER', {
time: firstCandle.time,
open: firstCandle.open,
high: 106,
low: 98,
close: 105,
volume: 30,
});
expect(mockUpdate).toHaveBeenNthCalledWith(3, 'MOEX:SBER', {
time: firstCandle.time,
open: firstCandle.open,
high: 107,
low: 97,
close: 106,
volume: 35,
});
expect(mockUpdate).toHaveBeenNthCalledWith(4, 'MOEX:SBER', nextTimeframeCandle);
});
it('should update distinct realtime candles without convolution', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const firstCandle = {
...mockBar,
close: 110,
};
const secondCandle = {
...mockBar,
close: 111,
};
mockRequestRealtimeBars.mockResolvedValueOnce(firstCandle).mockResolvedValueOnce(secondCandle);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockUpdate).toHaveBeenNthCalledWith(1, 'MOEX:SBER', firstCandle);
expect(mockUpdate).toHaveBeenNthCalledWith(2, 'MOEX:SBER', secondCandle);
});
it('should not update chart for duplicated realtime candle', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const realtimeCandle = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(2);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', realtimeCandle);
});
it('should not request realtime data for empty normalized symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const unsubscribe = provider.startRealtime({
getSymbols: () => [' '],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should replace existing realtime timer when startRealtime is called again', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
});
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;
}
function getRequestSymbol(symbolRaw?: string): string | undefined {
const symbol = String(symbolRaw ?? '').trim();
if (!symbol || symbol === DEFAULT_SYMBOL) {
return undefined;
}
return symbol;
}
function getTimeframeSeconds(timeframe: Timeframes): number {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}
function aggregateCandles(candles: Candle[], time: number): Candle | undefined {
const firstCandle = candles[0];
const lastCandle = candles[candles.length - 1];
if (!firstCandle || !lastCandle) {
return undefined;
}
return {
time,
open: firstCandle.open,
high: Math.max(...candles.map(({ high }) => high)),
low: Math.min(...candles.map(({ low }) => low)),
close: lastCandle.close,
volume: candles.reduce((total, candle) => total + (candle.volume ?? 0), 0),
};
}
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private prevRealtimeDataArr: Candle[] = [];
private prevRealtimeData: Candle | undefined;
private realtimeShouldBeConvoluted = false;
private realtimeSessionStart: number | null = null;
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 {
return await historyRequest;
} 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;
}
if (!this.realtimeShouldBeConvoluted) {
this.prevRealtimeData = data;
update(symbol, data);
return;
}
if (this.prevRealtimeData && JSON.stringify(data) === JSON.stringify(this.prevRealtimeData)) {
return;
}
this.realtimeConvolution(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: Math.round(Date.now() / 1000),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
if (data.length === 0) {
return null;
}
const issTimeframe = moexChartToIssTimeframe(timeframe);
if (issTimeframe === timeframe) {
this.realtimeShouldBeConvoluted = false;
if (!until) {
this.prevRealtimeData = data[data.length - 1];
this.prevRealtimeDataArr = [];
this.realtimeSessionStart = null;
}
return data;
}
this.realtimeShouldBeConvoluted = true;
return this.timeframeConvolution(data, timeframe, !until);
}
private timeframeConvolution(data: Candle[], requestedTimeframe: Timeframes, syncRealtime: boolean): Candle[] {
const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
const sortedData = [...data].sort((first, second) => first.time - second.time);
const firstCandle = sortedData[0];
if (!firstCandle) {
return [];
}
const result: Candle[] = [];
let sessionStart = firstCandle.time;
let bucketStart = sessionStart;
let candleGroup: Candle[] = [];
sortedData.forEach((candle, index) => {
const previousCandle = sortedData[index - 1];
const isNewSession = previousCandle && candle.time - previousCandle.time > timeframeSeconds;
if (isNewSession) {
const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
sessionStart = candle.time;
bucketStart = candle.time;
candleGroup = [candle];
return;
}
const currentBucketStart =
sessionStart + Math.floor((candle.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
if (currentBucketStart !== bucketStart) {
const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
bucketStart = currentBucketStart;
candleGroup = [];
}
candleGroup.push(candle);
});
const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
if (syncRealtime) {
this.realtimeSessionStart = sessionStart;
this.prevRealtimeDataArr = [...candleGroup];
this.prevRealtimeData = sortedData[sortedData.length - 1];
}
return result;
}
private realtimeConvolution(timeframe: Timeframes, data: Candle, update: (candle: Candle) => void): void {
const timeframeSeconds = getTimeframeSeconds(timeframe);
if (!this.prevRealtimeData || this.realtimeSessionStart === null) {
this.realtimeSessionStart = data.time;
this.prevRealtimeDataArr = [data];
} else {
const isNewSession = data.time - this.prevRealtimeData.time > timeframeSeconds;
if (isNewSession) {
this.realtimeSessionStart = data.time;
this.prevRealtimeDataArr = [data];
} else {
const previousBucketStart =
this.realtimeSessionStart +
Math.floor((this.prevRealtimeData.time - this.realtimeSessionStart) / timeframeSeconds) * timeframeSeconds;
const currentBucketStart =
this.realtimeSessionStart +
Math.floor((data.time - this.realtimeSessionStart) / timeframeSeconds) * timeframeSeconds;
if (currentBucketStart !== previousBucketStart) {
this.prevRealtimeDataArr = [data];
} else {
const candleIndex = this.prevRealtimeDataArr.findIndex((candle) => candle.time === data.time);
if (candleIndex === -1) {
this.prevRealtimeDataArr.push(data);
} else {
this.prevRealtimeDataArr[candleIndex] = data;
}
}
}
}
this.prevRealtimeData = data;
const sessionStart = this.realtimeSessionStart;
if (sessionStart === null) {
return;
}
const bucketStart = sessionStart + Math.floor((data.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
const candle = aggregateCandles(this.prevRealtimeDataArr, bucketStart);
if (candle) {
update(candle);
}
}
}
export { DataSourceProvider };
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 = 5 минут
// 10 = 10 минут
// 15 = 15 минут
// 30 = 30 минут
// 45 = 45 минут
// 60 = 1 час
// 120 = 2 часа
// 180 = 3 часа
// 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['2h']]: '60',
[Timeframes['3h']]: '60',
[Timeframes['4h']]: '60',
[Timeframes['1d']]: '24',
[Timeframes['1w']]: '7',
[Timeframes['1М']]: '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['2h']]: Timeframes['1h'],
[Timeframes['3h']]: Timeframes['1h'],
[Timeframes['4h']]: Timeframes['1h'],
[Timeframes['1d']]: Timeframes['1d'],
[Timeframes['1w']]: Timeframes['1w'],
[Timeframes['1М']]: Timeframes['1М'],
};
const INTERVALS: Record<string, string> = {
'1': '1',
'5': '5',
'10': '10',
'15': '15',
'30': '30',
'45': '45',
'60': '60',
'120': '120',
'180': '180',
'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];