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


import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';

import { Contract } from '@modules/contracts';
import '@testing-library/jest-dom';

import { MoexChartActions } from '../MoexChartActions';

describe('MoexChartActions', () => {
  const mockHandlers = {
    handleAbsolute: jest.fn(),
    handlePercent: jest.fn(),
    handleNewScale: jest.fn(),
    handleNewPanel: jest.fn(),
  };

  const mockContract = {
    issKey: 'RU000A0JQ0Y0',
  } as Contract;

  const renderComponent = (selectedInstrumentRows: Contract[] = [], isNewScaleDisabled = false) =>
    render(
      <MoexChartActions
        selectedInstrumentRows={selectedInstrumentRows}
        customActionsFooterHandlers={mockHandlers}
        isNewScaleDisabled={isNewScaleDisabled}
      />,
    );

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should render all action buttons', () => {
    renderComponent();

    expect(screen.getByRole('button', { name: 'Абсолютная шкала' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '%' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Новая шкала' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Новая панель' })).toBeInTheDocument();
  });

  it('should disable all action buttons when instrument is not selected', () => {
    renderComponent();

    expect(screen.getByRole('button', { name: 'Абсолютная шкала' })).toBeDisabled();
    expect(screen.getByRole('button', { name: '%' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая шкала' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая панель' })).toBeDisabled();
  });

  it('should enable all action buttons when instrument is selected', () => {
    renderComponent([mockContract]);

    expect(screen.getByRole('button', { name: 'Абсолютная шкала' })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: '%' })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая шкала' })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая панель' })).not.toBeDisabled();
  });

  it('should disable only new scale button when new scale is unavailable', () => {
    renderComponent([mockContract], true);

    expect(screen.getByRole('button', { name: 'Абсолютная шкала' })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: '%' })).not.toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая шкала' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Новая панель' })).not.toBeDisabled();
  });

  it.each([
    ['Абсолютная шкала', 'handleAbsolute'],
    ['%', 'handlePercent'],
    ['Новая шкала', 'handleNewScale'],
    ['Новая панель', 'handleNewPanel'],
  ] as const)('should call %s handler when button is clicked', (buttonText, handlerName) => {
    renderComponent([mockContract]);

    fireEvent.click(screen.getByRole('button', { name: buttonText }));

    expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
    expect(mockHandlers[handlerName]).toHaveBeenCalledWith(mockContract);
  });

  it('should use first instrument when multiple instruments are selected', () => {
    const multipleContracts = [
      {
        issKey: 'RU000A0JQ0Y0',
      },
      {
        issKey: 'RU000A0JQ0Y1',
      },
    ] as Contract[];

    renderComponent(multipleContracts);

    fireEvent.click(screen.getByRole('button', { name: 'Абсолютная шкала' }));
    fireEvent.click(screen.getByRole('button', { name: '%' }));
    fireEvent.click(screen.getByRole('button', { name: 'Новая шкала' }));
    fireEvent.click(screen.getByRole('button', { name: 'Новая панель' }));

    expect(mockHandlers.handleAbsolute).toHaveBeenCalledWith(multipleContracts[0]);
    expect(mockHandlers.handlePercent).toHaveBeenCalledWith(multipleContracts[0]);
    expect(mockHandlers.handleNewScale).toHaveBeenCalledWith(multipleContracts[0]);
    expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith(multipleContracts[0]);
  });
});


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 RealtimeState {
  previousTime: number | null;
  candles: Candle[];
  sessionStart: number | 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 isValidCandle({ time, open, high, low, close, volume }: Candle): boolean {
  const valuesAreValid = [time, open, high, low, close].every(Number.isFinite);

  return (
    valuesAreValid &&
    (volume === undefined || Number.isFinite(volume)) &&
    high >= Math.max(open, close) &&
    low <= Math.min(open, close)
  );
}

function isSameCandle(first: Candle | undefined, second: Candle): boolean {
  return (
    first?.time === second.time &&
    first.open === second.open &&
    first.high === second.high &&
    first.low === second.low &&
    first.close === second.close &&
    first.volume === second.volume
  );
}

function aggregateCandles(candles: Candle[], time: number): Candle | undefined {
  const firstCandle = candles[0];
  const lastCandle = candles[candles.length - 1];

  if (!firstCandle || !lastCandle) {
    return undefined;
  }

  const high = Math.max(...candles.map((candle) => candle.high));
  const low = Math.min(...candles.map((candle) => candle.low));
  const volume = candles.reduce((total, candle) => total + (candle.volume ?? 0), 0);

  return {
    time,
    open: firstCandle.open,
    high,
    low,
    close: lastCandle.close,
    volume,
  };
}

function appendAggregatedCandle(result: Candle[], candles: Candle[], time: number): void {
  const candle = aggregateCandles(candles, time);

  if (candle) {
    result.push(candle);
  }
}

function timeframeConvolution(
  data: Candle[],
  requestedTimeframe: Timeframes,
): {
  candles: Candle[];
  state: RealtimeState;
} {
  const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
  const sortedData = [...data].sort((first, second) => first.time - second.time);
  const firstCandle = sortedData[0];

  if (!firstCandle) {
    return {
      candles: [],
      state: {
        previousTime: null,
        candles: [],
        sessionStart: null,
      },
    };
  }

  const result: Candle[] = [];

  let sessionStart = firstCandle.time;
  let bucketStart = sessionStart;
  let previousTime: number | null = null;
  let candleGroup: Candle[] = [];

  sortedData.forEach((candle) => {
    const isNewSession = previousTime !== null && candle.time - previousTime > timeframeSeconds;

    if (isNewSession) {
      appendAggregatedCandle(result, candleGroup, bucketStart);

      sessionStart = candle.time;
      bucketStart = candle.time;
      candleGroup = [candle];
      previousTime = candle.time;

      return;
    }

    const currentBucketStart =
      sessionStart + Math.floor((candle.time - sessionStart) / timeframeSeconds) * timeframeSeconds;

    if (currentBucketStart !== bucketStart) {
      appendAggregatedCandle(result, candleGroup, bucketStart);

      bucketStart = currentBucketStart;
      candleGroup = [];
    }

    candleGroup.push(candle);
    previousTime = candle.time;
  });

  appendAggregatedCandle(result, candleGroup, bucketStart);

  return {
    candles: result,
    state: {
      previousTime,
      candles: candleGroup,
      sessionStart,
    },
  };
}

function realtimeConvolution(
  timeframe: Timeframes,
  data: Candle,
  currentState: RealtimeState,
): {
  candle: Candle | undefined;
  state: RealtimeState;
} {
  const timeframeSeconds = getTimeframeSeconds(timeframe);
  const { previousTime, sessionStart } = currentState;

  if (previousTime === null || sessionStart === null || data.time - previousTime > timeframeSeconds) {
    const state: RealtimeState = {
      previousTime: data.time,
      candles: [data],
      sessionStart: data.time,
    };

    return {
      candle: aggregateCandles(state.candles, data.time),
      state,
    };
  }

  const previousBucketStart =
    sessionStart + Math.floor((previousTime - sessionStart) / timeframeSeconds) * timeframeSeconds;

  const currentBucketStart =
    sessionStart + Math.floor((data.time - sessionStart) / timeframeSeconds) * timeframeSeconds;

  let candles: Candle[];

  if (currentBucketStart !== previousBucketStart) {
    candles = [data];
  } else {
    const candleIndex = currentState.candles.findIndex((candle) => candle.time === data.time);

    if (candleIndex === -1) {
      candles = [...currentState.candles, data];
    } else {
      candles = [...currentState.candles];
      candles[candleIndex] = data;
    }
  }

  const state: RealtimeState = {
    previousTime: data.time,
    candles,
    sessionStart,
  };

  return {
    candle: aggregateCandles(candles, currentBucketStart),
    state,
  };
}

// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
  private readonly realtimeStates = new Map<string, RealtimeState>();

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

      const validData = data.filter(isValidCandle);

      if (validData.length === 0) {
        return null;
      }

      if (moexChartToIssTimeframe(timeframe) === timeframe) {
        if (!until) {
          const lastCandle = validData.at(-1);

          this.realtimeStates.set(symbol, {
            previousTime: lastCandle?.time ?? null,
            candles: lastCandle ? [lastCandle] : [],
            sessionStart: null,
          });
        }

        return validData;
      }

      const { candles, state } = timeframeConvolution(validData, timeframe);

      if (!until) {
        this.realtimeStates.set(symbol, state);
      }

      return candles;
    };

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

      Promise.allSettled(
        getSymbols().map((symbolId) => this.updateRealtimeSymbol(symbolId, timeframe, update, indicativeData)),
      );
    }, periodMs);

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

      this.realtimeTimer = null;
    };
  }

  private async updateRealtimeSymbol(
    symbolId: string,
    timeframe: Timeframes,
    update: (symbolId: string, candle: Candle) => void,
    indicativeData?: ChartIndicativeData,
  ): Promise<void> {
    const symbol = getRequestSymbol(symbolId);

    if (!symbol) {
      return;
    }

    const data = await requestRealtimeBars({
      currencyPair: symbol.replaceAll(':', '.'),
      interval: moexChartTimeConverter(timeframe),
      ticker: symbol,
      indicativeData,
    });

    if (!data || !isValidCandle(data)) {
      return;
    }

    const currentState = this.realtimeStates.get(symbol) ?? {
      previousTime: null,
      candles: [],
      sessionStart: null,
    };

    const currentCandle = currentState.candles.find((candle) => candle.time === data.time);

    if (isSameCandle(currentCandle, data)) {
      return;
    }

    if (moexChartToIssTimeframe(timeframe) === timeframe) {
      this.realtimeStates.set(symbol, {
        previousTime: data.time,
        candles: [data],
        sessionStart: null,
      });

      update(symbol, data);

      return;
    }

    const { candle, state } = realtimeConvolution(timeframe, data, currentState);

    this.realtimeStates.set(symbol, state);

    if (candle) {
      update(symbol, candle);
    }
  }
}

export { DataSourceProvider };