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


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

const getCandleStartTime = (time: string): number => {
  const normalizedTime = /(?:Z|[+-]\d{2}:\d{2})$/i.test(time) ? time : `${time}Z`;

  return Math.floor(Date.parse(normalizedTime) / 1000);
};

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



import { Timeframes } from 'moex-chart';

// 1 = 1 минута
// 5 = 1 минут
// 10 = 10 минут
// 15 = 15 минут
// 30 = 30 минут
// 45 = 45 минут
// 60 = 1 час
// 240 = 4 часа
// 24 = 1 день
// 7 = 1 неделя
// 31 = 1 месяц
// 4 = 1 квартал

const MOEX_CHART_TIMEFRAMES_INTO_INTERVALS: Record<string, string> = {
  [Timeframes['1m']]: '1',
  [Timeframes['5m']]: '1',
  [Timeframes['10m']]: '1',
  [Timeframes['15m']]: '1',
  [Timeframes['30m']]: '1',
  [Timeframes['45m']]: '1',
  [Timeframes['1h']]: '60',
  [Timeframes['4h']]: '60',
  [Timeframes['1d']]: '24',
  [Timeframes['1w']]: '7',
  [Timeframes['1M']]: '31',
};

const MOEX_CHART_TIMEFRAMES_TO_ISS_POSSIBLE_TIMEFRAMES: Record<string, Timeframes> = {
  [Timeframes['1m']]: Timeframes['1m'],
  [Timeframes['5m']]: Timeframes['1m'],
  [Timeframes['10m']]: Timeframes['1m'],
  [Timeframes['15m']]: Timeframes['1m'],
  [Timeframes['30m']]: Timeframes['1m'],
  [Timeframes['45m']]: Timeframes['1m'],
  [Timeframes['1h']]: Timeframes['1h'],
  [Timeframes['4h']]: Timeframes['1h'],
  [Timeframes['1d']]: Timeframes['1d'],
  [Timeframes['1w']]: Timeframes['1w'],
  [Timeframes['1M']]: Timeframes['1M'],
};

const INTERVALS: Record<string, string> = {
  '1': '1',
  '5': '5',
  '10': '10',
  '15': '15',
  '30': '30',
  '45': '45',
  '60': '60',
  '240': '240',
  '1D': '24',
  '7D': '7',
  '1M': '31',
  '3M': '4',
};

export const chartToReqTimeConverter = (value: string) => INTERVALS[value];

export const moexChartTimeConverter = (timeframe: string) => MOEX_CHART_TIMEFRAMES_INTO_INTERVALS[timeframe];

export const moexChartToIssTimeframe = (timeframe: string) =>
  MOEX_CHART_TIMEFRAMES_TO_ISS_POSSIBLE_TIMEFRAMES[timeframe];



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 HistoryRequestState {
  untilTime?: number;
  request: Promise<Candle[] | null> | null;
}

interface RealtimeConvolutionState {
  alignmentOffset: number;
  timeframeSeconds: number;
  sourceCandles: Candle[];
  lastSourceCandle?: Candle;
}

interface TimeframeConvolutionResult {
  candles: Candle[];
  alignmentOffset: number;
  timeframeSeconds: number;
  latestSourceCandles: Candle[];
}

function getRequestSymbol(symbolRaw?: string): string | undefined {
  const symbol = String(symbolRaw ?? '').trim();

  if (!symbol || symbol === DEFAULT_SYMBOL) {
    return undefined;
  }

  return symbol;
}

function getConvolutionKey(symbol: string, timeframe: Timeframes): string {
  return `${symbol}:${timeframe}`;
}

function getTimeframeSeconds(timeframe: Timeframes): number {
  const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

  return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}

function getPositiveModulo(value: number, divisor: number): number {
  return ((value % divisor) + divisor) % divisor;
}

function getAlignmentOffset(data: Candle[], sourceTimeframeSeconds: number, timeframeSeconds: number): number {
  const firstCandle = data[0];

  if (!firstCandle) {
    return 0;
  }

  let sessionStart = firstCandle;
  let largestGap = 0;

  for (let index = 1; index < data.length; index += 1) {
    const previousCandle = data[index - 1];
    const currentCandle = data[index];

    if (!previousCandle || !currentCandle) {
      continue;
    }

    const gap = currentCandle.time - previousCandle.time;

    if (gap > sourceTimeframeSeconds && gap > largestGap) {
      largestGap = gap;
      sessionStart = currentCandle;
    }
  }

  return getPositiveModulo(sessionStart.time, timeframeSeconds);
}

function getBucketStart(time: number, timeframeSeconds: number, alignmentOffset: number): number {
  return Math.floor((time - alignmentOffset) / timeframeSeconds) * timeframeSeconds + alignmentOffset;
}

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

function aggregateCandles(data: Candle[], timeframeSeconds: number, alignmentOffset: number): Candle[] {
  const result: Candle[] = [];

  let currentBucketStart: number | null = null;
  let aggregatedCandle: Candle | null = null;

  data.forEach((candle) => {
    const bucketStart = getBucketStart(candle.time, timeframeSeconds, alignmentOffset);

    if (currentBucketStart !== bucketStart) {
      if (aggregatedCandle) {
        result.push(aggregatedCandle);
      }

      currentBucketStart = bucketStart;
      aggregatedCandle = {
        ...candle,
        time: bucketStart,
      };

      return;
    }

    if (aggregatedCandle) {
      aggregatedCandle = mergeCandles(aggregatedCandle, candle);
    }
  });

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

  return result;
}

function getLatestSourceCandles(
  data: Candle[],
  timeframeSeconds: number,
  alignmentOffset: number,
): Candle[] {
  const lastCandle = data[data.length - 1];

  if (!lastCandle) {
    return [];
  }

  const latestBucketStart = getBucketStart(lastCandle.time, timeframeSeconds, alignmentOffset);

  return data.filter(
    (candle) => getBucketStart(candle.time, timeframeSeconds, alignmentOffset) === latestBucketStart,
  );
}

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

  if (issTimeframe === requestedTimeframe) {
    return {
      candles: data,
      alignmentOffset: 0,
      timeframeSeconds: 0,
      latestSourceCandles: [],
    };
  }

  const sortedData = [...data].sort((firstCandle, secondCandle) => firstCandle.time - secondCandle.time);

  const sourceTimeframeSeconds = getTimeframeSeconds(issTimeframe);
  const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
  const alignmentOffset = getAlignmentOffset(sortedData, sourceTimeframeSeconds, timeframeSeconds);

  return {
    candles: aggregateCandles(sortedData, timeframeSeconds, alignmentOffset),
    alignmentOffset,
    timeframeSeconds,
    latestSourceCandles: getLatestSourceCandles(sortedData, timeframeSeconds, alignmentOffset),
  };
}

function areCandlesEqual(firstCandle: Candle | undefined, secondCandle: Candle): boolean {
  if (!firstCandle) {
    return false;
  }

  return (
    firstCandle.time === secondCandle.time &&
    firstCandle.open === secondCandle.open &&
    firstCandle.high === secondCandle.high &&
    firstCandle.low === secondCandle.low &&
    firstCandle.close === secondCandle.close &&
    firstCandle.volume === secondCandle.volume
  );
}

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

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

  private historyRequests = new Map<string, HistoryRequestState>();

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

      const historyRequestKey = `${symbol}:${timeframe}`;
      const historyRequestState = this.historyRequests.get(historyRequestKey);

      if (historyRequestState && historyRequestState.untilTime === until?.time) {
        if (historyRequestState.request) {
          return historyRequestState.request;
        }

        if (until) {
          return null;
        }
      }

      cb?.(timeframe);

      const historyRequest = this.requestHistoryData({
        timeframe,
        symbol,
        until,
        indicativeData,
      });

      this.historyRequests.set(historyRequestKey, {
        untilTime: until?.time,
        request: historyRequest,
      });

      try {
        const data = await historyRequest;

        return data;
      } finally {
        if (this.historyRequests.get(historyRequestKey)?.request === historyRequest) {
          if (until) {
            this.historyRequests.set(historyRequestKey, {
              untilTime: until.time,
              request: null,
            });
          } else {
            this.historyRequests.delete(historyRequestKey);
          }
        }
      }
    };

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

          const issTimeframe = moexChartToIssTimeframe(timeframe);

          if (issTimeframe === timeframe) {
            update(symbol, data);
            return;
          }

          this.realtimeConvolution(symbol, timeframe, data, (candle) => {
            update(symbol, candle);
          });
        }),
      );
    }, periodMs);

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

      this.realtimeTimer = null;
    };
  }

  private async requestHistoryData({
    timeframe,
    symbol,
    until,
    indicativeData,
  }: {
    timeframe: Timeframes;
    symbol: string;
    until?: Candle;
    indicativeData?: ChartIndicativeData;
  }): Promise<Candle[] | null> {
    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);
    const convolutionKey = getConvolutionKey(symbol, timeframe);

    if (issTimeframe === timeframe) {
      this.realtimeConvolutionStates.delete(convolutionKey);

      return data;
    }

    const {
      candles,
      alignmentOffset,
      timeframeSeconds,
      latestSourceCandles,
    } = timeframeConvolution(data, timeframe);

    this.realtimeConvolutionStates.set(convolutionKey, {
      alignmentOffset,
      timeframeSeconds,
      sourceCandles: latestSourceCandles,
      lastSourceCandle: data[data.length - 1],
    });

    return candles;
  }

  private realtimeConvolution(
    symbol: string,
    timeframe: Timeframes,
    data: Candle,
    update: (candle: Candle) => void,
  ): void {
    const convolutionKey = getConvolutionKey(symbol, timeframe);

    let state = this.realtimeConvolutionStates.get(convolutionKey);

    if (!state) {
      const timeframeSeconds = getTimeframeSeconds(timeframe);

      state = {
        alignmentOffset: getPositiveModulo(data.time, timeframeSeconds),
        timeframeSeconds,
        sourceCandles: [],
      };

      this.realtimeConvolutionStates.set(convolutionKey, state);
    }

    if (areCandlesEqual(state.lastSourceCandle, data)) {
      return;
    }

    const currentBucketStart = getBucketStart(
      data.time,
      state.timeframeSeconds,
      state.alignmentOffset,
    );

    const previousSourceCandle = state.sourceCandles[state.sourceCandles.length - 1];

    if (
      previousSourceCandle &&
      getBucketStart(
        previousSourceCandle.time,
        state.timeframeSeconds,
        state.alignmentOffset,
      ) !== currentBucketStart
    ) {
      state.sourceCandles = [data];
    } else {
      const candleIndex = state.sourceCandles.findIndex((candle) => candle.time === data.time);

      if (candleIndex === -1) {
        state.sourceCandles.push(data);
      } else {
        state.sourceCandles[candleIndex] = data;
      }
    }

    state.sourceCandles.sort((firstCandle, secondCandle) => firstCandle.time - secondCandle.time);
    state.lastSourceCandle = data;

    const firstCandle = state.sourceCandles[0];

    if (!firstCandle) {
      return;
    }

    const candle = state.sourceCandles.reduce<Candle>(
      (aggregatedCandle, currentCandle) => mergeCandles(aggregatedCandle, currentCandle),
      {
        ...firstCandle,
        time: currentBucketStart,
      },
    );

    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 { transformKeyToLowerCase } from './utils/transformKeyToLowerCase';

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 type CustomBarsResolver = (args: {
  ticker?: string;
  currencyPair: string;
  periodParams: PeriodParams;
  interval: string;
}) => Promise<Candle[]>;

const barsResolvers = new Map<string, CustomBarsResolver>();

export const registerBarsResolver = (boardKey: string, resolver: CustomBarsResolver) => {
  barsResolvers.set(boardKey, resolver);
};

const getResolverKey = (ticker?: string) => {
  const [source, board] = ticker?.split(/[:.]/) ?? [];

  if (!source || !board) {
    return '';
  }

  return `${source}:${board}`;
};

const getBarsResolver = (ticker?: string) => barsResolvers.get(getResolverKey(ticker));

export async function requestBars({
  currencyPair,
  interval,
  periodParams,
  onHistoryCallback,
  ticker,
  indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
  const customResolver = getBarsResolver(ticker);

  if (customResolver) {
    try {
      const customBars = await customResolver({
        ticker,
        currencyPair,
        periodParams,
        interval,
      });

      const olderBars = customBars.filter((bar) => bar.time < periodParams.to);

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

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

      return [];
    }
  }

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

    const lowerCaseKey = transformKeyToLowerCase(currencyPair);

    if (!lowerCaseKey) {
      onHistoryCallback?.([], {
        noData: true,
      });

      return [];
    }

    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: periodParams.countBack,
        key: lowerCaseKey,
        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> {
  if (getBarsResolver(ticker)) {
    return;
  }

  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

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

  if (isIndicativeInstrument) {
    const lowerCaseKey = transformKeyToLowerCase(currencyPair);

    if (!lowerCaseKey) {
      return undefined;
    }

    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: 1,
        key: lowerCaseKey,
        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;
  }
}