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


import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';

import { getStartTime, parseTimeframe } from 'moex-chart';

import {
  getISSOddTimeframePart,
  moexChartTimeConverter,
  moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';

import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';

import type { ManipulateType } from 'dayjs';
import type { Candle, Timeframes } from 'moex-chart';

dayjs.extend(duration);

interface ConvolutionParams {
  data: Candle[];
  moexCandle: number;
  requestedTimeframe: Timeframes;
  dayjsUnit: ManipulateType;
  iterationCounter: number;
}

interface CandleGroup {
  candle: Candle | null;
  consumedCount: number;
}

interface RealtimeParams {
  getSymbols: () => string[];
  getTimeframe: () => Timeframes;
  update: (symbol: string, candle: Candle) => void;
  periodMs?: number;
  indicativeData?: ChartIndicativeData;
}

function normalizeSymbol(symbolRaw?: string): string {
  return String(symbolRaw ?? '')
    .trim()
    .toUpperCase();
}

function mergeCandles(left: Candle, right: Candle): Candle {
  return {
    ...left,
    open: left.open,
    high: Math.max(left.high, right.high),
    low: Math.min(left.low, right.low),
    close: right.close,
    volume: (left.volume ?? 0) + (right.volume ?? 0),
  };
}

function aggregateCandles(candles: Candle[]): Candle | null {
  const firstCandle = candles[0];

  if (!firstCandle) {
    return null;
  }

  return candles
    .slice(1)
    .reduce(
      (aggregatedCandle, currentCandle) =>
        mergeCandles(aggregatedCandle, currentCandle),
      firstCandle,
    );
}

function getConvolutionEndTime(
  candle: Candle,
  requestedTimeframe: Timeframes,
  moexCandle: number,
  dayjsUnit: ManipulateType,
): number {
  return getStartTime(
    requestedTimeframe,
    dayjs(candle.time)
      .add(moexCandle, dayjsUnit)
      .unix() * 1000,
  );
}

function collectCandleGroup({
  data,
  startIndex,
  iterationCounter,
  convolutionEndTime,
}: {
  data: Candle[];
  startIndex: number;
  iterationCounter: number;
  convolutionEndTime: number;
}): CandleGroup {
  let aggregatedCandle: Candle | null = null;
  let consumedCount = 0;

  for (
    let offset = 0;
    offset < iterationCounter;
    offset += 1
  ) {
    const currentCandle = data[startIndex + offset];

    if (!currentCandle) {
      break;
    }

    aggregatedCandle = aggregatedCandle
      ? mergeCandles(aggregatedCandle, currentCandle)
      : currentCandle;

    consumedCount += 1;

    const isLastCandle =
      startIndex + offset + 1 === data.length;

    const isOutsideCurrentInterval =
      currentCandle.time > convolutionEndTime * 1000;

    if (isLastCandle || isOutsideCurrentInterval) {
      break;
    }
  }

  return {
    candle: aggregatedCandle,
    consumedCount,
  };
}

function proceedConvolution({
  data,
  moexCandle,
  requestedTimeframe,
  dayjsUnit,
  iterationCounter,
}: ConvolutionParams): Candle[] {
  const result: Candle[] = [];

  let index = 0;

  while (index < data.length - 1) {
    const firstCandle = data[index];

    if (!firstCandle) {
      break;
    }

    const convolutionEndTime = getConvolutionEndTime(
      firstCandle,
      requestedTimeframe,
      moexCandle,
      dayjsUnit,
    );

    const group = collectCandleGroup({
      data,
      startIndex: index,
      iterationCounter,
      convolutionEndTime,
    });

    if (!group.candle || group.consumedCount === 0) {
      break;
    }

    result.push(group.candle);
    index += group.consumedCount;
  }

  return result;
}

function findConvolutionStartIndex({
  data,
  dataStartTime,
  value,
  unit,
}: {
  data: Candle[];
  dataStartTime: number;
  value: number;
  unit: ManipulateType;
}): number | null {
  const searchLimit = Math.min(data.length, 60);

  for (let index = 0; index < searchLimit; index += 1) {
    const candle = data[index];

    if (!candle) {
      return null;
    }

    const shiftedCandleTime = dayjs(candle.time)
      .subtract(value, unit)
      .unix();

    if (shiftedCandleTime === dataStartTime) {
      return index;
    }
  }

  return null;
}

function timeframeConvolution(
  data: Candle[],
  requestedTimeframe: Timeframes,
): Candle[] {
  const issTimeframe =
    moexChartToIssTimeframe(requestedTimeframe);

  if (issTimeframe === requestedTimeframe) {
    return data;
  }

  const firstCandle = data[0];

  if (!firstCandle) {
    return [];
  }

  const {
    candleWidth: moexCandle,
    dayjsUnit,
  } = parseTimeframe(requestedTimeframe);

  const dataStartTime = getConvolutionEndTime(
    firstCandle,
    requestedTimeframe,
    moexCandle,
    dayjsUnit,
  );

  const { value, unit } =
    getISSOddTimeframePart(issTimeframe);

  const startIndex = findConvolutionStartIndex({
    data,
    dataStartTime,
    value,
    unit,
  });

  if (startIndex === null) {
    return [];
  }

  const { candleWidth: issCandle } =
    parseTimeframe(issTimeframe);

  return proceedConvolution({
    data: data.slice(startIndex),
    moexCandle,
    requestedTimeframe,
    dayjsUnit,
    iterationCounter: moexCandle / issCandle,
  });
}

// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
  private prevRealtimeDataArr: Candle[] = [];

  private prevRealtimeData: Candle | undefined;

  private realtimeShouldBeConvoluted = false;

  private realtimeTimer:
    | ReturnType<typeof setInterval>
    | null = null;

  public getDataSource =
    (
      indicativeData?: ChartIndicativeData,
      cb?: (timeframe: Timeframes) => void,
    ) =>
    async (
      timeframe: Timeframes,
      symbol: string,
      until?: Candle,
    ): Promise<Candle[] | 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: Date.now(),
          countBack: 2000,
        },
        ticker: symbol,
        indicativeData,
      });

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

      const issTimeframe =
        moexChartToIssTimeframe(timeframe);

      this.realtimeShouldBeConvoluted =
        issTimeframe !== timeframe;

      return this.realtimeShouldBeConvoluted
        ? timeframeConvolution(data, timeframe)
        : data;
    };

  public startRealtime({
    getSymbols,
    getTimeframe,
    update,
    periodMs = 5000,
    indicativeData,
  }: RealtimeParams): () => void {
    this.stopRealtime();

    this.realtimeTimer = setInterval(() => {
      void this.updateRealtimeSymbols({
        symbols: getSymbols(),
        timeframe: getTimeframe(),
        update,
        indicativeData,
      });
    }, periodMs);

    return () => {
      this.stopRealtime();
    };
  }

  private stopRealtime(): void {
    if (this.realtimeTimer) {
      clearInterval(this.realtimeTimer);
    }

    this.realtimeTimer = null;
  }

  private async updateRealtimeSymbols({
    symbols,
    timeframe,
    update,
    indicativeData,
  }: {
    symbols: string[];
    timeframe: Timeframes;
    update: (symbol: string, candle: Candle) => void;
    indicativeData?: ChartIndicativeData;
  }): Promise<void> {
    await Promise.all(
      symbols.map((symbol) =>
        this.updateRealtimeSymbol({
          symbol,
          timeframe,
          update,
          indicativeData,
        }),
      ),
    );
  }

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

    if (!symbol) {
      return;
    }

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

    if (!data) {
      return;
    }

    this.processRealtimeData({
      symbol,
      timeframe,
      data,
      update,
    });
  }

  private processRealtimeData({
    symbol,
    timeframe,
    data,
    update,
  }: {
    symbol: string;
    timeframe: Timeframes;
    data: Candle;
    update: (symbol: string, candle: Candle) => void;
  }): void {
    if (!this.prevRealtimeData) {
      this.prevRealtimeData = data;
      this.prevRealtimeDataArr = [data];

      update(symbol, data);

      return;
    }

    if (this.areCandlesEqual(data, this.prevRealtimeData)) {
      return;
    }

    const candle = this.buildRealtimeCandle(
      timeframe,
      data,
    );

    update(symbol, candle);
  }

  private areCandlesEqual(
    firstCandle: Candle,
    secondCandle: Candle,
  ): boolean {
    return (
      JSON.stringify(firstCandle) ===
      JSON.stringify(secondCandle)
    );
  }

  private buildRealtimeCandle(
    timeframe: Timeframes,
    data: Candle,
  ): Candle {
    if (
      !this.realtimeShouldBeConvoluted ||
      !this.prevRealtimeData
    ) {
      this.prevRealtimeData = data;
      this.prevRealtimeDataArr = [data];

      return data;
    }

    if (
      this.isNextTimeframeStep(
        timeframe,
        this.prevRealtimeData,
        data,
      )
    ) {
      this.prevRealtimeData = data;
      this.prevRealtimeDataArr = [data];

      return data;
    }

    this.updateRealtimeBuffer(data);

    const candle =
      aggregateCandles(this.prevRealtimeDataArr) ?? data;

    this.prevRealtimeData = data;

    return candle;
  }

  private isNextTimeframeStep(
    timeframe: Timeframes,
    previousData: Candle,
    currentData: Candle,
  ): boolean {
    const {
      candleWidth: moexCandle,
      dayjsUnit,
    } = parseTimeframe(timeframe);

    const previousStartTime = getConvolutionEndTime(
      previousData,
      timeframe,
      moexCandle,
      dayjsUnit,
    );

    const currentStartTime = getConvolutionEndTime(
      currentData,
      timeframe,
      moexCandle,
      dayjsUnit,
    );

    return previousStartTime !== currentStartTime;
  }

  private updateRealtimeBuffer(data: Candle): void {
    const lastBufferIndex =
      this.prevRealtimeDataArr.length - 1;

    const isNewBarInsideTimeframe =
      this.prevRealtimeData?.time !== data.time;

    if (
      isNewBarInsideTimeframe ||
      lastBufferIndex < 0
    ) {
      this.prevRealtimeDataArr.push(data);

      return;
    }

    this.prevRealtimeDataArr[lastBufferIndex] = data;
  }
}

export { DataSourceProvider };