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


import { Bar } from '@modules/charting_library';
import { Candle } from 'types/Candles';

// Возвращает время завершения свечи
const getCandleEndTime = (time: string, interval: number | string) => {
  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, ticker, volume, end, interval }: Candle): Bar => ({
  open,
  close,
  high,
  low,
  // TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
  // в котировках volume и value значения всегда null
  volume: volume || 0,
  time: getCandleEndTime(end, interval),
});


import dayjs, { ManipulateType } from 'dayjs';
import duration from 'dayjs/plugin/duration';

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

import { Bar } from '@modules/charting_library';

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

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

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;
}) => {
  const res: Candle[] = [];

  let i = 0;
  while (i < data.length - 1) {
    let nextRes: Candle | undefined;
    const startTime = getStartTime(requestedTimeframe, dayjs(data[i].time).add(moexCandle, dayjsUnit).unix() * 1000);
    let j = 0;
    while (j < iterationCounter) {
      if (!nextRes) {
        nextRes = data[i + j];
      } else {
        nextRes = {
          ...nextRes,
          open: nextRes.open,
          high: Math.max(data[i + j]!.high, nextRes.high),
          low: Math.min(data[i + j]!.low, nextRes.low),
          close: data[i + j].close,
          volume: (nextRes!.volume ?? 0) + (data[i + j]!.volume ?? 0),
        };
      }

      if (i + j + 1 === data.length || data[i + j].time > startTime * 1000) {
        break;
      }
      j += 1;
    }

    res.push(nextRes!);
    i += j;
  }

  return res;
};

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

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

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

  const dataStartTime = getStartTime(requestedTimeframe, dayjs(data[0].time).add(moexCandle, dayjsUnit).unix() * 1000);

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

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

  if (k > 59) {
    return [];
  }

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

  const iterationCounter = moexCandle / issCandle;
  const dataCropped = data.slice(k);

  return proceedConvolution({
    data: dataCropped,
    moexCandle,
    requestedTimeframe,
    dayjsUnit,
    iterationCounter,
  });
};

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

  private prevRealtimeData: Bar | undefined = undefined;

  private realtimeShouldBeConvoluted = false;

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

  public getDataSource = (indicativeData?: ChartIndicativeData | undefined, cb?: (tf: Timeframes) => void) => {
    const res = 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 issTF = moexChartToIssTimeframe(timeframe);

      if (issTF === timeframe) {
        this.realtimeShouldBeConvoluted = false;
        return data;
      }

      this.realtimeShouldBeConvoluted = true;

      return timeframeConvolution(data, timeframe);
    };

    return res;
  };

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

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

      symbols.forEach(async (value) => {
        const symbol = normalizeSymbol(value);

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

        if (data) {
          if (!this.prevRealtimeData) {
            this.prevRealtimeData = data;
            this.prevRealtimeDataArr = [data];
            update(symbol, data);
          } else if (this.prevRealtimeData && JSON.stringify(data) !== JSON.stringify(this.prevRealtimeData)) {
            this.realtimeConvolution(timeframe, data, (candle: Candle) => update(symbol, candle));
          }
        }
      });
    }, periodMs);

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

      this.realtimeTimer = null;
    };
  }

  private realtimeConvolution(timeframe: Timeframes, data: Bar, update: (candle: Candle) => void) {
    let candle = data;
    if (this.realtimeShouldBeConvoluted) {
      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 isNextTFStep = dataStartTime !== dataStartTimeFromPrevData;

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

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

        candle = {
          time: this.prevRealtimeDataArr[0].time,
          open: this.prevRealtimeDataArr[0].open,
          high: Math.max(...this.prevRealtimeDataArr.map((bar: Bar) => bar.high)),
          low: Math.min(...this.prevRealtimeDataArr.map((bar: Bar) => bar.low)),
          close: this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1].close,
          volume: this.prevRealtimeDataArr.reduce((acc, curr) => acc + (curr.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 { HistoryCallback, PeriodParams, SubscribeBarsCallback } from '@modules/charting_library';
import { candleToBar } from '@utils/candleToBar';

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

dayjs.extend(utc);

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) {
  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 и текущий symbolInfo из трединг вью совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  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 (e) {
      onHistoryCallback?.([], { noData: true });

      return [];
    }
  } else {
    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 (e) {
      onHistoryCallback?.([], { noData: true });

      return [];
    }
  }
}

export const requestRealtimeBars = async ({
  currencyPair,
  interval,
  ticker,
  onRealtimeCallback,
  indicativeData,
}: RequestRealtimeBarsArgs) => {
  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

  // если при инициализации графика были данные indicativeData и текущий symbolInfo из трединг вью совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  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;
      }

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

      const bar = candleToBar(sortedData[0]);

      onRealtimeCallback?.(bar);

      return bar;
    } catch (e) {
      console.error('error from requestRealTimeBars indicativeQuotesController: ', e);
    }
  } else {
    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;
      }

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

      const bar = candleToBar(sortedData[0]);

      onRealtimeCallback?.(bar);

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


import { SearchSymbolResultItem } from '@modules/charting_library/charting_library/datafeed-api';

import { Contract } from '@modules/contracts';
import { instrumentNames } from '@modules/quotes';
import { ContractsWithNewColumns } from '@widgets/Chart/datafeed';

const MAX_RESULTS = 500;

const matchesUserInput = (property: string | undefined | null, userInput: string) =>
  property?.toLowerCase().includes(userInput);

export const filterUserInputWithNewColumns = (i: ContractsWithNewColumns, lowerCaseUserInput: string) =>
  matchesUserInput(i.symbol, lowerCaseUserInput) ||
  matchesUserInput(i.board, lowerCaseUserInput) ||
  matchesUserInput(i.shortName, lowerCaseUserInput) ||
  matchesUserInput(i.secName, lowerCaseUserInput) ||
  matchesUserInput(i.latName, lowerCaseUserInput) ||
  matchesUserInput(i.instrName, lowerCaseUserInput) ||
  matchesUserInput(i.instrGroupType, lowerCaseUserInput) ||
  matchesUserInput(i.boardName, lowerCaseUserInput) ||
  matchesUserInput(i.ccy, lowerCaseUserInput) ||
  matchesUserInput(i.instrFullName, lowerCaseUserInput) ||
  matchesUserInput(i.instrSubtypeName, lowerCaseUserInput) ||
  matchesUserInput(i.currencyType, lowerCaseUserInput) ||
  matchesUserInput(i.symbolWithBoard, lowerCaseUserInput) ||
  matchesUserInput(i.symbolWithBoardName, lowerCaseUserInput);

export const filterUserInput = (i: Contract, lowerCaseUserInput: string) =>
  matchesUserInput(i.symbol, lowerCaseUserInput) ||
  matchesUserInput(i.board, lowerCaseUserInput) ||
  matchesUserInput(i.shortName, lowerCaseUserInput) ||
  matchesUserInput(i.secName, lowerCaseUserInput) ||
  matchesUserInput(i.latName, lowerCaseUserInput) ||
  matchesUserInput(i.instrName, lowerCaseUserInput) ||
  matchesUserInput(i.instrGroupType, lowerCaseUserInput) ||
  matchesUserInput(i.boardName, lowerCaseUserInput) ||
  matchesUserInput(i.ccy, lowerCaseUserInput) ||
  matchesUserInput(i.instrFullName, lowerCaseUserInput) ||
  matchesUserInput(i.instrSubtypeName, lowerCaseUserInput) ||
  matchesUserInput(i.currencyType, lowerCaseUserInput);

export const mapToTVSearchObject = (instr: Contract | ContractsWithNewColumns) => ({
  full_name: instr.displayName || '',
  ticker: instr.issKey || '',
  description: instr.boardName || '',
  exchange: instrumentNames[instr.instrType || ''] || '',
  type: '',
  symbol: instr.displayName || '',
});

export const filterIsPrimary = (contracts: Contract[]) => contracts.filter(({ isPrimaryBoard }) => isPrimaryBoard);

export const searchSymbolFunction = (userInput: string, instruments: Contract[]): SearchSymbolResultItem[] => {
  if (!instruments || instruments.length === 0) {
    return [];
  }

  const contractsIsPrimary: Contract[] = filterIsPrimary(instruments);
  const lowerCaseUserInput = userInput.toLowerCase();

  const newResultsPrimary: SearchSymbolResultItem[] = contractsIsPrimary
    .filter((i) => filterUserInput(i, lowerCaseUserInput))
    .map(mapToTVSearchObject);

  let result: SearchSymbolResultItem[] = [];

  if (newResultsPrimary.length !== 0) {
    result = newResultsPrimary;
  } else {
    // если не нашли среди отфильтрованных по isPrimaryBoard инструментов, то ищем среди всех
    // и добавляем дополнительные поля для проверки пользовательского ввода в определенном формате
    const contractsWithNewColumns: ContractsWithNewColumns[] = instruments.map((elem) => ({
      ...elem,
      symbolWithBoard: `${elem.symbol} ${elem.board}`,
      symbolWithBoardName: `${elem.symbol} ${elem.boardName}`,
    }));

    result = contractsWithNewColumns
      .filter((i) => filterUserInputWithNewColumns(i, lowerCaseUserInput))
      .map(mapToTVSearchObject);
  }

  return result.slice(0, MAX_RESULTS);
};