Загрузка данных
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,
},
},
};
};