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


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 collectCandleGroup = ({
  data,
  startIndex,
  iterationCounter,
  startTime,
}: {
  data: Candle[];
  startIndex: number;
  iterationCounter: number;
  startTime: number;
}): {
  aggregatedCandle: Candle | undefined;
  consumedCount: number;
} => {
  let aggregatedCandle: Candle | undefined;
  let offset = 0;
  let consumedCount = 0;

  while (offset < iterationCounter) {
    const currentCandle = data[startIndex + 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 (startIndex + offset + 1 === data.length || currentCandle.time > startTime * 1000) {
      break;
    }

    offset += 1;
  }

  return {
    aggregatedCandle,
    consumedCount,
  };
};

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

    const { aggregatedCandle, consumedCount } = collectCandleGroup({
      data,
      startIndex: index,
      iterationCounter,
      startTime,
    });

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

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







/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no--requires */

const path = require('path');

require('dotenv').config({ path: '.env.local' });
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const CircularDependencyPlugin = require('circular-dependency-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { ESBuildPlugin } = require('esbuild-loader');
const ForkTSCheckerPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ReactRefreshTypeScript = require('react-refresh-typescript');
const TerserPlugin = require('terser-webpack-plugin');
const { ids } = require('webpack');

const { HashedModuleIdsPlugin } = ids;

let standSwitcher;

try {
  standSwitcher = require('./dev/stands/index.js');
} catch {}

const PORT = 3000;

const MAX_CYCLES = 84;
let numCyclesDetected = 0;

const getOptimization = (isDevelopment) =>
  isDevelopment
    ? {
        minimizer: [
          new TerserPlugin({
            extractComments: false,
          }),
        ],
      }
    : {
        runtimeChunk: 'single',
        splitChunks: {
          chunks: 'all',
          maxInitialRequests: Infinity,
          minSize: 0,
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name(module) {
                // получает имя, то есть node_modules/packageName/not/this/part.js
                // или node_modules/packageName
                const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

                // имена npm-пакетов можно, не опасаясь проблем, использовать
                // в URL, но некоторые серверы не любят символы наподобие @
                return `npm.${packageName.replace('@', '')}`;
              },
            },
          },
        },
        minimizer: [
          new TerserPlugin({
            extractComments: false,
          }),
        ],
      };

module.exports = (env) => {
  const isDevelopment = env.type === 'start';
  const currentStand = process.env.DEV_STAND || env.stand || standSwitcher?.getDefaultStand();

  const localDevUrl = `${standSwitcher?.getLocalDevUrl(currentStand) || 'localhost'}:${PORT}`;

  const proxy = {
    target: '',
    secure: false,
    changeOrigin: true,
    ws: true,
    // Этот заголовок нужен для правильного редиректа обратно на локальный домен после авторизации
    headers: {
      'X-Dev-Redirect': localDevUrl,
    },
  };

  return {
    entry: './src/index.tsx',
    devtool: isDevelopment ? 'source-map' : false,
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
    },
    plugins: [
      new CircularDependencyPlugin({
        // exclude detection of files based on a RegExp
        exclude: /a\.js|node_modules/,
        // include specific files based on a RegExp
        // include: /dir/,
        // add errors to webpack instead of warnings
        // failOnError: true,
        // allow import cycles that include an asyncronous import,
        // e.g. via import(/* webpackMode: "weak" */ './file.js')
        allowAsyncCycles: false,
        // set the current working directory for displaying module paths
        cwd: process.cwd(),
        onStart({ compilation }) {
          numCyclesDetected = 0;
        },
        onDetected({ module: webpackModuleRecord, paths, compilation }) {
          numCyclesDetected += 1;
          compilation.warnings.push(new Error(paths.join(' -> ')));
        },
        onEnd({ compilation }) {
          if (numCyclesDetected > MAX_CYCLES) {
            compilation.errors.push(
              new Error(`Detected ${numCyclesDetected} cycles which exceeds configured limit of ${MAX_CYCLES}`),
            );
          }
        },
      }),
      new HtmlWebpackPlugin({
        template: 'src/index.html',
        publicPath: env.staticContextPath || 'auto',
      }),
      new MiniCssExtractPlugin({
        filename: '[name].[contenthash].css',
      }),
      new CopyWebpackPlugin({
        patterns: [
          { from: 'public' },
          isDevelopment && { from: standSwitcher?.getPathForCopyPlugin(currentStand) || 'env' },
        ].filter(Boolean),
      }),
      isDevelopment && new ReactRefreshWebpackPlugin({ overlay: false }),
      new HashedModuleIdsPlugin(),
      new ForkTSCheckerPlugin({}),
    ].filter(Boolean),
    resolve: {
      extensions: ['.ts', '.tsx', '.js'],
      extensionAlias: {
        '.js': ['.js', '.ts'],
        '.cjs': ['.cjs', '.cts'],
        '.mjs': ['.mjs', '.mts'],
      },
      alias: {
        '@modules': path.resolve(__dirname, 'src/modules'),
        '@widgets': path.resolve(__dirname, 'src/widgets'),
        '@core': path.resolve(__dirname, 'src/core'),
        '@uikit': path.resolve(__dirname, 'src/uikit'),
        '@pages': path.resolve(__dirname, 'src/pages'),
        '@components': path.resolve(__dirname, 'src/components'),
        '@styles': path.resolve(__dirname, 'src/styles'),
        '@store': path.resolve(__dirname, 'src/store'),
        types: path.resolve(__dirname, 'src/types'),
        '@configs': path.resolve(__dirname, 'src/configs'),
        '@hooks': path.resolve(__dirname, 'src/hooks'),
        '@api': path.resolve(__dirname, 'src/api'),
        '@utils': path.resolve(__dirname, 'src/utils'),
        '@localisation': path.resolve(__dirname, 'src/localisation'),
        '@features': path.resolve(__dirname, 'src/features'),
        '@terminal': path.resolve(__dirname, 'src/terminal'),
        '@testing': path.resolve(__dirname, 'src/testing'),
      },
    },
    optimization: getOptimization(isDevelopment),
    module: {
      rules: [
        {
          test: /\.d\.ts$/,
          use: 'null-loader',
        },
        {
          test: /\.(woff|woff2|eot|ttf|otf)$/i,
          type: 'asset/resource',
          generator: {
            filename: 'static/fonts/[name][ext]',
          },
        },
        {
          test: /\.[jt]sx?$/,
          exclude: /(node_modules)/,
          use: [
            {
              loader: 'ts-loader',
              options: {
                transpileOnly: true,
                getCustomTransformers: () => ({
                  before: [isDevelopment && ReactRefreshTypeScript()].filter(Boolean),
                }),
              },
            },
          ],
        },
        {
          test: /\.s[ac]ss$/i,
          use: [
            env.production ? MiniCssExtractPlugin.loader : 'style-loader',
            {
              loader: 'css-loader',
              options: isDevelopment
                ? {
                    modules: {
                      auto: true,
                      localIdentName: '[local]--[hash:base64:5]',
                    },
                  }
                : {
                    modules: {
                      auto: true,
                      hashStrategy: 'minimal-subset',
                      localIdentHashDigestLength: 5,
                    },
                  },
            },
            {
              loader: 'sass-loader',
              options: {
                sassOptions: {
                  includePaths: ['./src/styles'],
                },
              },
            },
          ],
        },
        {
          test: /\.(png|jpe?g|gif|mp(3|4))$/i,
          use: [
            {
              loader: 'file-loader',
            },
          ],
        },
        {
          test: /\.css$/i,
          use: [env.production ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'],
        },
        {
          test: /\.mdx$/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-react'],
              },
            },
            {
              loader: '@mdx-js/loader',
            },
          ],
        },
        {
          test: /\.svg$/,
          issuer: /\.[jt]sx?$/,
          use: ['@svgr/webpack', 'url-loader'],
        },
      ],
    },
    devServer: {
      server: 'https',
      allowedHosts: 'all',
      static: {
        directory: path.join(__dirname, 'public'),
      },
      compress: true,
      port: PORT,
      client: {
        overlay: false,
        progress: true,
      },
      historyApiFallback: true,
      open: {
        target: localDevUrl,
      },
      proxy: {
        '/session': standSwitcher?.modifyProxy(currentStand, proxy) || proxy,
        '/ntbagro': standSwitcher?.modifyProxy(currentStand, proxy) || proxy,
      },
    },
  };
};