Загрузка данных
if (!data) {
return;
}
if (this.prevRealtimeData && JSON.stringify(data) === JSON.stringify(this.prevRealtimeData)) {
return;
}
if (!this.realtimeShouldBeConvoluted) {
this.prevRealtimeData = data;
update(symbol, data);
return;
}
this.realtimeConvolution(timeframe, data, (candle) => {
update(symbol, candle);
});
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 mockMoexChartToIssTimeframe = jest.fn();
jest.mock('moex-chart', () => ({
Timeframes: {
'1m': '1m',
'5m': '5m',
'1h': '1h',
'2h': '2h',
'3h': '3h',
'4h': '4h',
},
parseTimeframe: mockParseTimeframe,
}));
jest.mock('@utils/chartToReqTimeConverter', () => ({
moexChartTimeConverter: mockMoexChartTimeConverter,
moexChartToIssTimeframe: mockMoexChartToIssTimeframe,
}));
jest.mock('@widgets/Chart/requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
const { DataSourceProvider } = jest.requireActual(
'@widgets/Chart/components/MoexChart/dataSourceProvide',
) as DataSourceProvideModule;
const Timeframes = {
'1m': '1m' as TimeframesType,
'5m': '5m' as TimeframesType,
'1h': '1h' as TimeframesType,
'2h': '2h' as TimeframesType,
'3h': '3h' as TimeframesType,
'4h': '4h' as TimeframesType,
};
const baseTime = Math.floor(Date.parse('2026-05-19T10:00:00Z') / 1000);
const mockBar = {
time: baseTime,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
const createBar = (minute: number, overrides: Partial<typeof mockBar> = {}) => ({
time: baseTime + minute * 60,
open: 100 + minute,
close: 101 + minute,
high: 102 + minute,
low: 99 - minute,
volume: minute + 1,
...overrides,
});
const createHourBar = (startTime: number, hour: number) => ({
time: startTime + hour * 60 * 60,
open: 100 + hour,
close: 101 + hour,
high: 102 + hour,
low: 99 - hour,
volume: hour + 1,
});
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const runRealtimeTick = async (periodMs = 1000): Promise<void> => {
jest.advanceTimersByTime(periodMs);
await flushPromises();
};
const configureConvolution = (
requestedTimeframe: TimeframesType,
sourceTimeframe: TimeframesType,
): void => {
mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) =>
timeframe === requestedTimeframe ? sourceTimeframe : timeframe,
);
};
describe('DataSourceProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-19T10:00:00Z'));
mockMoexChartTimeConverter.mockReturnValue('1');
mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) => timeframe);
mockParseTimeframe.mockImplementation((timeframe: TimeframesType) => {
switch (timeframe) {
case Timeframes['5m']:
return {
candleWidth: 5,
dayjsUnit: 'minutes',
};
case Timeframes['2h']:
return {
candleWidth: 2,
dayjsUnit: 'hours',
};
case Timeframes['3h']:
return {
candleWidth: 3,
dayjsUnit: 'hours',
};
case Timeframes['4h']:
return {
candleWidth: 4,
dayjsUnit: 'hours',
};
case Timeframes['1h']:
return {
candleWidth: 1,
dayjsUnit: 'hours',
};
default:
return {
candleWidth: 1,
dayjsUnit: 'minutes',
};
}
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('should request chart history data with converted timeframe', async () => {
const mockTimeframeCallback = jest.fn();
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
const now = Math.round(Date.now() / 1000);
expect(mockTimeframeCallback).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockMoexChartTimeConverter).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockRequestBars).toHaveBeenCalledWith({
currencyPair: 'MOEX.SBER',
interval: '1',
periodParams: {
firstDataRequest: true,
to: now,
from: now,
countBack: 2000,
},
ticker: 'MOEX:SBER',
indicativeData: undefined,
});
expect(result).toEqual([mockBar]);
});
it('should normalize symbol before requesting chart history data', async () => {
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
await dataSource(Timeframes['1m'], ' MOEX:SBER ');
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
currencyPair: 'MOEX.SBER',
ticker: 'MOEX:SBER',
}),
);
});
it('should not request chart history data for default symbol', async () => {
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['1m'], DEFAULT_SYMBOL);
expect(result).toBeNull();
expect(mockRequestBars).not.toHaveBeenCalled();
});
it('should not request chart history data for default symbol with surrounding spaces', async () => {
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['1m'], ` ${DEFAULT_SYMBOL} `);
expect(result).toBeNull();
expect(mockRequestBars).not.toHaveBeenCalled();
});
it('should not request chart history data for empty symbol', async () => {
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['1m'], ' ');
expect(result).toBeNull();
expect(mockRequestBars).not.toHaveBeenCalled();
});
it('should request chart history data with indicative data', async () => {
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);
await dataSource(Timeframes['1m'], 'MOEX:SBER');
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should use until time when it is provided', async () => {
mockMoexChartTimeConverter.mockReturnValue('5');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = {
time: baseTime - 60,
} as NonNullable<Parameters<typeof dataSource>[2]>;
await dataSource(Timeframes['5m'], 'MOEX:GAZP', until);
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
periodParams: expect.objectContaining({
to: until.time,
}),
}),
);
});
it('should return null when history data is empty', async () => {
mockRequestBars.mockResolvedValue([]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
expect(result).toBeNull();
});
it('should reuse pending history request for the same symbol, timeframe and until', async () => {
let resolveRequest: ((value: typeof mockBar[]) => void) | undefined;
mockRequestBars.mockImplementation(
() =>
new Promise<typeof mockBar[]>((resolve) => {
resolveRequest = resolve;
}),
);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = {
time: baseTime - 60,
} as NonNullable<Parameters<typeof dataSource>[2]>;
const firstRequest = dataSource(Timeframes['1m'], 'MOEX:SBER', until);
const secondRequest = dataSource(Timeframes['1m'], 'MOEX:SBER', until);
expect(mockRequestBars).toHaveBeenCalledTimes(1);
resolveRequest?.([mockBar]);
await expect(firstRequest).resolves.toEqual([mockBar]);
await expect(secondRequest).resolves.toEqual([mockBar]);
});
it('should not repeat completed history request with the same until', async () => {
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = {
time: baseTime - 60,
} as NonNullable<Parameters<typeof dataSource>[2]>;
const firstResult = await dataSource(Timeframes['1m'], 'MOEX:SBER', until);
const secondResult = await dataSource(Timeframes['1m'], 'MOEX:SBER', until);
expect(firstResult).toEqual([mockBar]);
expect(secondResult).toBeNull();
expect(mockRequestBars).toHaveBeenCalledTimes(1);
});
it('should request initial history again after previous initial request is completed', async () => {
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
await dataSource(Timeframes['1m'], 'MOEX:SBER');
await dataSource(Timeframes['1m'], 'MOEX:SBER');
expect(mockRequestBars).toHaveBeenCalledTimes(2);
});
it('should request realtime data and update normalized symbol', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const realtimeBar = {
...mockBar,
volume: 500,
};
mockRequestRealtimeBars.mockResolvedValue(realtimeBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => [' moex:sber '],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
currencyPair: 'moex.sber',
interval: '1',
ticker: 'moex:sber',
indicativeData: undefined,
});
expect(mockUpdate).toHaveBeenCalledWith('moex:sber', realtimeBar);
});
it('should not request realtime data for default symbol', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => [DEFAULT_SYMBOL],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data for default symbol with surrounding spaces', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => [` ${DEFAULT_SYMBOL} `],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data for empty normalized symbol', async () => {
const provider = new DataSourceProvider();
const unsubscribe = provider.startRealtime({
getSymbols: () => [' '],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should request realtime data with indicative data', async () => {
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);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
indicativeData,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should not call update when realtime data is empty', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
mockRequestRealtimeBars.mockResolvedValue(undefined);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data when symbols list is empty', async () => {
const provider = new DataSourceProvider();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => [],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should clear realtime timer on unsubscribe', async () => {
const provider = new DataSourceProvider();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
unsubscribe();
await runRealtimeTick();
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should replace existing realtime timer when startRealtime is called again', async () => {
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,
});
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
it('should update distinct realtime candles without convolution', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const firstCandle = {
...mockBar,
close: 110,
};
const secondCandle = {
...mockBar,
time: mockBar.time + 60,
close: 111,
};
mockRequestRealtimeBars.mockResolvedValueOnce(firstCandle).mockResolvedValueOnce(secondCandle);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
await runRealtimeTick();
unsubscribe();
expect(mockUpdate).toHaveBeenNthCalledWith(1, 'MOEX:SBER', firstCandle);
expect(mockUpdate).toHaveBeenNthCalledWith(2, 'MOEX:SBER', secondCandle);
});
it('should not update chart for duplicated realtime candle', async () => {
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
await runRealtimeTick();
await runRealtimeTick();
unsubscribe();
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(2);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', mockBar);
});
it('should convolve five minute history data from one minute candles', async () => {
configureConvolution(Timeframes['5m'], Timeframes['1m']);
const historyData = Array.from({ length: 11 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
expect(result).toEqual([
{
time: baseTime,
open: 100,
close: 105,
high: 106,
low: 95,
volume: 15,
},
{
time: baseTime + 5 * 60,
open: 105,
close: 110,
high: 111,
low: 90,
volume: 40,
},
{
time: baseTime + 10 * 60,
open: 110,
close: 111,
high: 112,
low: 89,
volume: 11,
},
]);
});
it('should convolve incomplete history candle group', async () => {
configureConvolution(Timeframes['5m'], Timeframes['1m']);
const historyData = Array.from({ length: 8 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
expect(result).toEqual([
{
time: baseTime,
open: 100,
close: 105,
high: 106,
low: 95,
volume: 15,
},
{
time: baseTime + 5 * 60,
open: 105,
close: 108,
high: 109,
low: 92,
volume: 21,
},
]);
});
it('should start a new convolution session after a large gap', async () => {
configureConvolution(Timeframes['5m'], Timeframes['1m']);
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();
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
expect(result).toEqual([
{
time: baseTime,
open: 100,
close: 105,
high: 106,
low: 95,
volume: 15,
},
{
time: baseTime + 5 * 60,
open: 105,
close: 106,
high: 107,
low: 94,
volume: 6,
},
{
time: baseTime + 11 * 60,
open: 111,
close: 113,
high: 114,
low: 87,
volume: 25,
},
]);
});
it.each([
[Timeframes['2h'], 5, [0, 2, 4]],
[Timeframes['3h'], 7, [0, 3, 6]],
])(
'should convolve %s history from one hour candles',
async (timeframe, candlesCount, expectedHourOffsets) => {
configureConvolution(timeframe, Timeframes['1h']);
mockMoexChartTimeConverter.mockReturnValue('60');
const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
const historyData = Array.from({ length: candlesCount }, (_, hour) => createHourBar(sessionStart, hour));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(timeframe, 'MOEX:SBER');
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
interval: '60',
}),
);
expect(result?.map(({ time }) => time)).toEqual(
expectedHourOffsets.map((hour) => sessionStart + hour * 60 * 60),
);
},
);
it('should align four hour candles to the beginning of the trading session', async () => {
configureConvolution(Timeframes['4h'], Timeframes['1h']);
mockMoexChartTimeConverter.mockReturnValue('60');
const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
const historyData = Array.from({ length: 12 }, (_, hour) => createHourBar(sessionStart, hour));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['4h'], 'MOEX:SBER');
expect(result?.map(({ time }) => time)).toEqual([
sessionStart,
sessionStart + 4 * 60 * 60,
sessionStart + 8 * 60 * 60,
]);
});
it('should reset four hour convolution at the next trading session', async () => {
configureConvolution(Timeframes['4h'], Timeframes['1h']);
const firstSessionStart = Math.floor(Date.parse('2026-07-30T03:00:00Z') / 1000);
const secondSessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
const historyData = [
...Array.from({ length: 9 }, (_, hour) => createHourBar(firstSessionStart, hour)),
...Array.from({ length: 5 }, (_, hour) => createHourBar(secondSessionStart, hour)),
];
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const result = await dataSource(Timeframes['4h'], 'MOEX:SBER');
expect(result?.map(({ time }) => time)).toEqual([
firstSessionStart,
firstSessionStart + 4 * 60 * 60,
firstSessionStart + 8 * 60 * 60,
secondSessionStart,
secondSessionStart + 4 * 60 * 60,
]);
});
it('should append, replace and reset realtime candles during convolution', async () => {
configureConvolution(Timeframes['5m'], Timeframes['1m']);
mockRequestBars.mockResolvedValue(Array.from({ length: 11 }, (_, minute) => createBar(minute)));
const provider = new DataSourceProvider();
await provider.getDataSource()(Timeframes['5m'], 'MOEX:SBER');
const firstCandle = {
time: baseTime + 10 * 60,
open: 100,
high: 103,
low: 99,
close: 102,
volume: 10,
};
const secondCandle = {
time: baseTime + 11 * 60,
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 + 15 * 60,
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,
});
await runRealtimeTick();
await runRealtimeTick();
await runRealtimeTick();
await runRealtimeTick();
unsubscribe();
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);
});
});