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


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