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


import type { Candle as MoexChartCandle } from 'moex-chart';
import type { Candle as ApiCandle } from 'types/Candles';

// Возвращает время завершения свечи
const getCandleEndTime = (time: string, interval: number | string): number => {
  const parsedDate = Date.parse(`${time}Z`);

  if ([1, 'MINUTE_1'].includes(interval)) {
    // 1 минута
    const ms = 60 * 1000;

    return Math.ceil(parsedDate / ms) * ms - 1;
  }

  if ([10, 'MINUTE_10'].includes(interval)) {
    // 10 минут
    const ms = 10 * 60 * 1000;

    return Math.ceil(parsedDate / ms) * ms - 1;
  }

  if ([60, 'HOUR_1'].includes(interval)) {
    // 60 минут
    const ms = 60 * 60 * 1000;

    return Math.ceil(parsedDate / ms) * ms - 1;
  }

  if ([24, 'DAY_1'].includes(interval)) {
    // 24 часа
    const ms = 24 * 60 * 60 * 1000;

    return Math.ceil(parsedDate / ms) * ms - 1;
  }

  return parsedDate;
};

export const candleToBar = ({
  open,
  close,
  high,
  low,
  volume,
  end,
  interval,
}: ApiCandle): MoexChartCandle => ({
  open,
  close,
  high,
  low,
  // TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
  // в котировках volume и value значения всегда null
  volume: volume || 0,
  time: getCandleEndTime(end, interval),
});





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

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

const proceedConvolution = ({
  data,
  moexCandle,
  requestedTimeframe,
  dayjsUnit,
  iterationCounter,
}: {
  data: Candle[];
  moexCandle: number;
  requestedTimeframe: Timeframes;
  dayjsUnit: ManipulateType;
  iterationCounter: number;
}): Candle[] => {
  const result: Candle[] = [];

  let index = 0;

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

    if (!firstCandle) {
      break;
    }

    const startTime = getStartTime(
      requestedTimeframe,
      dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
    );

    let aggregatedCandle: Candle | undefined;
    let offset = 0;
    let consumedCount = 0;

    while (offset < iterationCounter) {
      const currentCandle = data[index + offset];

      if (!currentCandle) {
        break;
      }

      if (!aggregatedCandle) {
        aggregatedCandle = currentCandle;
      } else {
        aggregatedCandle = {
          ...aggregatedCandle,
          high: Math.max(currentCandle.high, aggregatedCandle.high),
          low: Math.min(currentCandle.low, aggregatedCandle.low),
          close: currentCandle.close,
          volume: (aggregatedCandle.volume ?? 0) + (currentCandle.volume ?? 0),
        };
      }

      consumedCount += 1;

      if (index + offset + 1 === data.length || currentCandle.time > startTime * 1000) {
        break;
      }

      offset += 1;
    }

    if (!aggregatedCandle || consumedCount === 0) {
      break;
    }

    result.push(aggregatedCandle);
    index += consumedCount;
  }

  return result;
};

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

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

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

  const firstCandle = data[0];

  if (!firstCandle) {
    return [];
  }

  const dataStartTime = getStartTime(
    requestedTimeframe,
    dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
  );

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

  let startIndex = 0;

  while (
    startIndex < data.length &&
    startIndex < 60 &&
    dataStartTime !== dayjs(data[startIndex].time).subtract(value, unit).unix()
  ) {
    startIndex += 1;
  }

  if (startIndex >= data.length || startIndex >= 60) {
    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,
  ) => {
    return async (timeframe: Timeframes, symbol: string, until?: Candle) => {
      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);

      if (issTimeframe === timeframe) {
        this.realtimeShouldBeConvoluted = false;

        return data;
      }

      this.realtimeShouldBeConvoluted = true;

      return timeframeConvolution(data, timeframe);
    };
  };

  public startRealtime({
    getSymbols,
    getTimeframe,
    update,
    periodMs = 5000,
    indicativeData,
  }: {
    getSymbols: () => string[];
    getTimeframe: () => Timeframes;
    update: (symbol: string, candle: Candle) => void;
    periodMs?: number;
    indicativeData?: ChartIndicativeData;
  }): () => void {
    if (this.realtimeTimer) {
      clearInterval(this.realtimeTimer);
    }

    this.realtimeTimer = setInterval(() => {
      const timeframe = getTimeframe();
      const symbols = getSymbols();

      void Promise.all(
        symbols.map(async (value) => {
          const symbol = normalizeSymbol(value);

          if (!symbol) {
            return;
          }

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

          if (!data) {
            return;
          }

          if (!this.prevRealtimeData) {
            this.prevRealtimeData = data;
            this.prevRealtimeDataArr = [data];
            update(symbol, data);

            return;
          }

          if (JSON.stringify(data) !== JSON.stringify(this.prevRealtimeData)) {
            this.realtimeConvolution(timeframe, data, (candle) => {
              update(symbol, candle);
            });
          }
        }),
      );
    }, periodMs);

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

      this.realtimeTimer = null;
    };
  }

  private realtimeConvolution(
    timeframe: Timeframes,
    data: Candle,
    update: (candle: Candle) => void,
  ): void {
    let candle = data;

    if (this.realtimeShouldBeConvoluted && this.prevRealtimeData) {
      const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(timeframe);

      const dataStartTimeFromPrevData = getStartTime(
        timeframe,
        dayjs(this.prevRealtimeData.time).add(moexCandle, dayjsUnit).unix() * 1000,
      );

      const dataStartTime = getStartTime(
        timeframe,
        dayjs(data.time).add(moexCandle, dayjsUnit).unix() * 1000,
      );

      const isNextTimeframeStep = dataStartTime !== dataStartTimeFromPrevData;

      if (isNextTimeframeStep) {
        this.prevRealtimeDataArr = [data];
      } else {
        const isNewBarInsideTimeframe = this.prevRealtimeData.time !== data.time;

        if (isNewBarInsideTimeframe) {
          this.prevRealtimeDataArr.push(data);
        } else {
          this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1] = data;
        }

        const firstCandle = this.prevRealtimeDataArr[0];
        const lastCandle = this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1];

        if (firstCandle && lastCandle) {
          candle = {
            time: firstCandle.time,
            open: firstCandle.open,
            high: Math.max(...this.prevRealtimeDataArr.map(({ high }) => high)),
            low: Math.min(...this.prevRealtimeDataArr.map(({ low }) => low)),
            close: lastCandle.close,
            volume: this.prevRealtimeDataArr.reduce(
              (total, currentCandle) => total + (currentCandle.volume ?? 0),
              0,
            ),
          };
        }
      }
    }

    this.prevRealtimeData = data;

    update(candle);
  }
}

export { DataSourceProvider };








import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';

import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';

import { ChartIndicativeData } from './types';
import { isIndicativeTicker } from './utils/isIndicativeTicker';

import type { Candle } from 'moex-chart';

dayjs.extend(utc);

export interface PeriodParams {
  from: number;
  to: number;
  countBack: number;
  firstDataRequest: boolean;
}

export interface HistoryMetadata {
  noData: boolean;
}

export type HistoryCallback = (candles: Candle[], metadata: HistoryMetadata) => void;

export type SubscribeBarsCallback = (candle: Candle) => void;

interface RequestBarsArgs {
  currencyPair: string;
  interval: string;
  periodParams: PeriodParams;
  onHistoryCallback?: HistoryCallback;
  ticker?: string;
  indicativeData?: ChartIndicativeData;
}

interface RequestRealtimeBarsArgs {
  currencyPair: string;
  interval: string;
  ticker?: string;
  indicativeData?: ChartIndicativeData;
  onRealtimeCallback?: SubscribeBarsCallback;
}

export async function requestBars({
  currencyPair,
  interval,
  periodParams,
  onHistoryCallback,
  ticker,
  indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
  const date = new Date(periodParams.to * 1000);
  const year = date.getUTCFullYear();
  const month = `0${date.getUTCMonth() + 1}`.slice(-2);
  const day = `0${date.getUTCDate()}`.slice(-2);
  const hours = `0${date.getUTCHours()}`.slice(-2);
  const minutes = `0${date.getUTCMinutes()}`.slice(-2);
  const seconds = `0${date.getUTCSeconds()}`.slice(-2);

  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

  // если при инициализации графика были данные indicativeData и текущий инструмент совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  const isIndicativeInstrument =
    (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;

  if (isIndicativeInstrument) {
    const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;

    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: periodParams.countBack,
        key: currencyPair,
        date: dateStr,
        interval,
      });

      const candles = data.indicativeCandles.reverse().map(candleToBar);

      onHistoryCallback?.(candles, {
        noData: candles.length === 0,
      });

      return candles;
    } catch {
      onHistoryCallback?.([], {
        noData: true,
      });

      return [];
    }
  }

  const dateStr = `${year}-${month}-${day}%20${hours}:${minutes}:${seconds}`;

  try {
    const { data } = await api.getBars({
      currencyPair,
      date: dateStr,
      interval,
      count: periodParams.countBack,
      ticker,
    });

    const bars = data.reverse().map(candleToBar);

    onHistoryCallback?.(bars, {
      noData: bars.length === 0,
    });

    return bars;
  } catch {
    onHistoryCallback?.([], {
      noData: true,
    });

    return [];
  }
}

export async function requestRealtimeBars({
  currencyPair,
  interval,
  ticker,
  onRealtimeCallback,
  indicativeData,
}: RequestRealtimeBarsArgs): Promise<Candle | undefined> {
  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

  // если при инициализации графика были данные indicativeData и текущий инструмент совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  const isIndicativeInstrument =
    (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;

  if (isIndicativeInstrument) {
    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: 1,
        key: currencyPair,
        date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DDTHH:mm:ss'),
        interval,
      });

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

      const sortedData = [...data.indicativeCandles].sort(
        (first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
      );

      const firstCandle = sortedData[0];

      if (!firstCandle) {
        return undefined;
      }

      const bar = candleToBar(firstCandle);

      onRealtimeCallback?.(bar);

      return bar;
    } catch (error) {
      console.error('error from requestRealTimeBars indicativeQuotesController: ', error);

      return undefined;
    }
  }

  try {
    const { data } = await api.getBars({
      currencyPair,
      date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DD%20HH:mm:ss'),
      interval,
      count: 1,
      ticker,
    });

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

    const sortedData = [...data].sort(
      (first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
    );

    const firstCandle = sortedData[0];

    if (!firstCandle) {
      return undefined;
    }

    const bar = candleToBar(firstCandle);

    onRealtimeCallback?.(bar);

    return bar;
  } catch (error) {
    console.error('error from requestRealTimeBars: ', error);

    return undefined;
  }
}




git grep -n -I -E "@modules/charting_library|charting_library|TradingView|tradingview|SearchSymbolResultItem|IChartingLibraryWidget|ResolutionString"