Загрузка данных


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);

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;

  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;
      }

      cb?.(timeframe);

      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);
    };

  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.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);
          });
        }),
      );
    }, periodMs);

    return () => {
      if (this.realtimeTimer) {
        clearInterval(this.realtimeTimer);
      }

      this.realtimeTimer = null;
    };
  }

  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 { 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 mockMoexChartToIssTimeframe = jest.fn();
const mockParseTimeframe = 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 createMinuteBar = (startTime: number, minute: number) => ({
  time: startTime + minute * 60,
  open: 100 + minute,
  close: 101 + minute,
  high: 102 + minute,
  low: 99 - minute,
  volume: minute + 1,
});

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 (): Promise<void> => {
  jest.advanceTimersByTime(1000);
  await flushPromises();
};

describe('DataSourceProvider', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2026-05-19T10:00:00Z'));

    mockMoexChartTimeConverter.mockImplementation((timeframe: TimeframesType) => {
      switch (timeframe) {
        case Timeframes['1m']:
        case Timeframes['5m']:
          return '1';
        case Timeframes['1h']:
        case Timeframes['2h']:
        case Timeframes['3h']:
        case Timeframes['4h']:
          return '60';
        default:
          return undefined;
      }
    });

    mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) => {
      switch (timeframe) {
        case Timeframes['5m']:
          return Timeframes['1m'];
        case Timeframes['2h']:
        case Timeframes['3h']:
        case Timeframes['4h']:
          return Timeframes['1h'];
        default:
          return timeframe;
      }
    });

    mockParseTimeframe.mockImplementation((timeframe: TimeframesType) => {
      switch (timeframe) {
        case Timeframes['5m']:
          return {
            candleWidth: 5,
            dayjsUnit: 'minute',
          };
        case Timeframes['1h']:
          return {
            candleWidth: 1,
            dayjsUnit: 'hour',
          };
        case Timeframes['2h']:
          return {
            candleWidth: 2,
            dayjsUnit: 'hour',
          };
        case Timeframes['3h']:
          return {
            candleWidth: 3,
            dayjsUnit: 'hour',
          };
        case Timeframes['4h']:
          return {
            candleWidth: 4,
            dayjsUnit: 'hour',
          };
        default:
          return {
            candleWidth: 1,
            dayjsUnit: 'minute',
          };
      }
    });
  });

  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 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 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 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 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 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 for history request', async () => {
    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['1m'], 'MOEX:SBER', 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 return history data without convolution when ISS timeframe matches requested timeframe', async () => {
    const historyData = [createHourBar(baseTime, 0), createHourBar(baseTime, 1), createHourBar(baseTime, 2)];

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    const result = await dataSource(Timeframes['1h'], 'MOEX:SBER');

    expect(result).toEqual(historyData);
  });

  it('should convolve five minute history from one minute candles', async () => {
    const sessionStart = Math.floor(Date.parse('2026-05-19T07:00:00Z') / 1000);
    const historyData = Array.from({ length: 11 }, (_, minute) => createMinuteBar(sessionStart, minute));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');

    expect(result).toEqual([
      {
        time: sessionStart,
        open: 100,
        close: 105,
        high: 106,
        low: 95,
        volume: 15,
      },
      {
        time: sessionStart + 5 * 60,
        open: 105,
        close: 110,
        high: 111,
        low: 90,
        volume: 40,
      },
      {
        time: sessionStart + 10 * 60,
        open: 110,
        close: 111,
        high: 112,
        low: 89,
        volume: 11,
      },
    ]);
  });

  it.each([
    {
      timeframe: Timeframes['2h'],
      sourceCount: 5,
      expectedOffsets: [0, 2, 4],
    },
    {
      timeframe: Timeframes['3h'],
      sourceCount: 7,
      expectedOffsets: [0, 3, 6],
    },
  ])(
    'should convolve $timeframe history from one hour candles',
    async ({ timeframe, sourceCount, expectedOffsets }) => {
      const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
      const historyData = Array.from({ length: sourceCount }, (_, 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(expectedOffsets.map((hour) => sessionStart + hour * 60 * 60));
    },
  );

  it('should build four hour candles from the beginning of the trading session', async () => {
    const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
    const historyData = Array.from({ length: 17 }, (_, hour) => createHourBar(sessionStart, hour));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    const result = await dataSource(Timeframes['4h'], 'MOEX:LKOH');

    expect(mockRequestBars).toHaveBeenCalledWith(
      expect.objectContaining({
        interval: '60',
      }),
    );

    expect(result?.map(({ time }) => new Date(time * 1000).toISOString())).toEqual([
      '2026-07-31T03:00:00.000Z',
      '2026-07-31T07:00:00.000Z',
      '2026-07-31T11:00:00.000Z',
      '2026-07-31T15:00:00.000Z',
      '2026-07-31T19:00:00.000Z',
    ]);
  });

  it('should aggregate four hour OHLCV data correctly', async () => {
    const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
    const historyData = Array.from({ length: 4 }, (_, hour) => createHourBar(sessionStart, hour));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    const result = await dataSource(Timeframes['4h'], 'MOEX:LKOH');

    expect(result).toEqual([
      {
        time: sessionStart,
        open: 100,
        close: 104,
        high: 105,
        low: 96,
        volume: 10,
      },
    ]);
  });

  it('should reset four hour convolution for the next trading session', async () => {
    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: 18 }, (_, hour) => createHourBar(firstSessionStart, hour)),
      ...Array.from({ length: 17 }, (_, hour) => createHourBar(secondSessionStart, hour)),
    ];

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    const result = await dataSource(Timeframes['4h'], 'MOEX:LKOH');

    expect(result?.map(({ time }) => new Date(time * 1000).toISOString())).toEqual([
      '2026-07-30T03:00:00.000Z',
      '2026-07-30T07:00:00.000Z',
      '2026-07-30T11:00:00.000Z',
      '2026-07-30T15:00:00.000Z',
      '2026-07-30T19:00:00.000Z',
      '2026-07-31T03:00:00.000Z',
      '2026-07-31T07:00:00.000Z',
      '2026-07-31T11:00:00.000Z',
      '2026-07-31T15:00:00.000Z',
      '2026-07-31T19:00:00.000Z',
    ]);
  });

  it('should keep current realtime state when older history is loaded', async () => {
    const currentSessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
    const olderSessionStart = Math.floor(Date.parse('2026-07-30T03:00:00Z') / 1000);

    const currentHistory = Array.from({ length: 15 }, (_, hour) => createHourBar(currentSessionStart, hour));
    const olderHistory = Array.from({ length: 10 }, (_, hour) => createHourBar(olderSessionStart, hour));

    mockRequestBars.mockResolvedValueOnce(currentHistory).mockResolvedValueOnce(olderHistory);

    const provider = new DataSourceProvider();
    const dataSource = provider.getDataSource();

    await dataSource(Timeframes['4h'], 'MOEX:LKOH');

    await dataSource(Timeframes['4h'], 'MOEX:LKOH', {
      time: olderSessionStart,
    } as NonNullable<Parameters<typeof dataSource>[2]>);

    const realtimeCandle = {
      time: currentSessionStart + 15 * 60 * 60,
      open: 118,
      high: 120,
      low: 84,
      close: 119,
      volume: 30,
    };

    mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);

    const mockUpdate = jest.fn();

    const unsubscribe = provider.startRealtime({
      getSymbols: () => ['MOEX:LKOH'],
      getTimeframe: () => Timeframes['4h'],
      update: mockUpdate,
      periodMs: 1000,
    });

    await runRealtimeTick();

    unsubscribe();

    expect(mockUpdate).toHaveBeenCalledWith('MOEX:LKOH', {
      time: currentSessionStart + 12 * 60 * 60,
      open: 112,
      high: 120,
      low: 84,
      close: 119,
      volume: 72,
    });
  });

  it('should continue current four hour candle in realtime', async () => {
    const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
    const historyData = Array.from({ length: 15 }, (_, hour) => createHourBar(sessionStart, hour));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();

    await provider.getDataSource()(Timeframes['4h'], 'MOEX:LKOH');

    const realtimeCandle = {
      time: sessionStart + 15 * 60 * 60,
      open: 118,
      high: 120,
      low: 84,
      close: 119,
      volume: 30,
    };

    mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);

    const mockUpdate = jest.fn();

    const unsubscribe = provider.startRealtime({
      getSymbols: () => ['MOEX:LKOH'],
      getTimeframe: () => Timeframes['4h'],
      update: mockUpdate,
      periodMs: 1000,
    });

    await runRealtimeTick();

    unsubscribe();

    expect(mockUpdate).toHaveBeenCalledWith('MOEX:LKOH', {
      time: sessionStart + 12 * 60 * 60,
      open: 112,
      high: 120,
      low: 84,
      close: 119,
      volume: 72,
    });
  });

  it('should start next four hour candle in realtime', async () => {
    const sessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);
    const historyData = Array.from({ length: 16 }, (_, hour) => createHourBar(sessionStart, hour));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();

    await provider.getDataSource()(Timeframes['4h'], 'MOEX:LKOH');

    const realtimeCandle = {
      time: sessionStart + 16 * 60 * 60,
      open: 116,
      high: 118,
      low: 115,
      close: 117,
      volume: 30,
    };

    mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);

    const mockUpdate = jest.fn();

    const unsubscribe = provider.startRealtime({
      getSymbols: () => ['MOEX:LKOH'],
      getTimeframe: () => Timeframes['4h'],
      update: mockUpdate,
      periodMs: 1000,
    });

    await runRealtimeTick();

    unsubscribe();

    expect(mockUpdate).toHaveBeenCalledWith('MOEX:LKOH', realtimeCandle);
  });

  it('should reset realtime convolution for a new trading session', async () => {
    const sessionStart = Math.floor(Date.parse('2026-07-30T03:00:00Z') / 1000);
    const historyData = Array.from({ length: 18 }, (_, hour) => createHourBar(sessionStart, hour));

    mockRequestBars.mockResolvedValue(historyData);

    const provider = new DataSourceProvider();

    await provider.getDataSource()(Timeframes['4h'], 'MOEX:LKOH');

    const nextSessionStart = Math.floor(Date.parse('2026-07-31T03:00:00Z') / 1000);

    const realtimeCandle = {
      time: nextSessionStart,
      open: 200,
      high: 202,
      low: 198,
      close: 201,
      volume: 50,
    };

    mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);

    const mockUpdate = jest.fn();

    const unsubscribe = provider.startRealtime({
      getSymbols: () => ['MOEX:LKOH'],
      getTimeframe: () => Timeframes['4h'],
      update: mockUpdate,
      periodMs: 1000,
    });

    await runRealtimeTick();

    unsubscribe();

    expect(mockUpdate).toHaveBeenCalledWith('MOEX:LKOH', realtimeCandle);
  });

  it('should request realtime data and update normalized symbol', 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();

    unsubscribe();

    expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
      currencyPair: 'moex.sber',
      interval: '1',
      ticker: 'moex:sber',
      indicativeData: undefined,
    });
    expect(mockUpdate).toHaveBeenCalledWith('moex:sber', mockBar);
  });

  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 empty 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 not 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();

    const unsubscribe = provider.startRealtime({
      getSymbols: () => [],
      getTimeframe: () => Timeframes['1m'],
      update: jest.fn(),
      periodMs: 1000,
    });

    await runRealtimeTick();

    unsubscribe();

    expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
  });

  it('should not update duplicated realtime candle twice', 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 update distinct realtime candles without convolution', async () => {
    const provider = new DataSourceProvider();
    const mockUpdate = jest.fn();

    const firstCandle = {
      ...mockBar,
      close: 111,
    };

    const secondCandle = {
      ...mockBar,
      time: mockBar.time + 60,
      close: 112,
    };

    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 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);
  });
});