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


diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf1ff796554908057b3128cb16a513bf580ec23f..3650fd7b2837d684f142f5e2fc3f709d94463e9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # latest
 
+- Добавлен индикатор RSI
+- Исправлен баг, когда линия EMA уходила в будущее
+
+# 0.1.21
+
 - Добавлена возможность добавлять инструменты сравнения на абсолютную шкалу
 - Исправлена работа режима «Продолжить в режиме рисования» для линейки
 - Добавлен быстрый запуск линейки с помощью Shift
diff --git a/src/constants/indicator.ts b/src/constants/indicator.ts
index 323eca43079134f2343721dbd60352345661e979..aac36aa7b01fd602d3f302387686599f7b3881f1 100644
--- a/src/constants/indicator.ts
+++ b/src/constants/indicator.ts
@@ -5,6 +5,7 @@ export enum IndicatorsIds { // todo: rename to IndicatorsTypes
   SMA = 'sma',
   EMA = 'ema',
   MACD = 'macd',
+  RSI = 'RSI',
 }
 
 export const indicatorSeriesLabelById: Partial<Record<IndicatorsIds, Record<string, string>>> = {
@@ -22,4 +23,5 @@ export const indicatorLabelById = () => ({
   [IndicatorsIds.SMA]: 'SMA',
   [IndicatorsIds.EMA]: 'EMA',
   [IndicatorsIds.MACD]: 'MACD',
+  [IndicatorsIds.RSI]: 'RSI',
 });
diff --git a/src/core/Indicator.ts b/src/core/Indicator.ts
index f4faa7227620cf30c4fc239c2cdac4ab2c89a7df..d17870ca1caff9c3b3b3de7e715a235b344f3984 100644
--- a/src/core/Indicator.ts
+++ b/src/core/Indicator.ts
@@ -215,7 +215,7 @@ export class Indicator extends DOMObject implements ISerializable<IndicatorSnaps
 
   private createSeries(): void {
     this.config.series.forEach(
-      ({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions, actLikeMainSerie }) => {
+      ({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions, actLikeMainSerie, onSerieInit }) => {
         const serie = SeriesFactory.create(name!)({
           lwcChart: this.lwcChart,
           dataSource: this.dataSource,
@@ -238,6 +238,8 @@ export class Indicator extends DOMObject implements ISerializable<IndicatorSnaps
           actLikeMainSerie,
         });
 
+        onSerieInit?.(serie);
+
         const handleDataChanged = () => {
           this.notifyDataChanged();
         };
diff --git a/src/core/Indicators/ema.ts b/src/core/Indicators/ema.ts
index 1904aed0e7fc8d359e374e6f14fa7b7927b35b11..af22e68404c644e483bf0bb33e3f69d96865c3fa 100644
--- a/src/core/Indicators/ema.ts
+++ b/src/core/Indicators/ema.ts
@@ -83,10 +83,8 @@ export function calculatePreciseEMASeriesData(
   const candleIndexToCalculate = candleData.findIndex((c) => c.time === candle.time);
 
   if (candleIndexToCalculate === -1) {
-    console.error('[Indicators]: нет подходящей свечи в массиве');
     return {
       time: candle.time as Time,
-      value: 0,
     };
   }
 
@@ -109,7 +107,6 @@ export function calculateEMASeriesData(
   candleData: LineCandle[],
   maLength: number,
 ): SeriesDataItemTypeMap<Time>['Line'][] {
-  // todo: change signature to {time & value}
   const maData: LineData<Time>[] = [];
 
   const smoothing = 2;
diff --git a/src/core/Indicators/index.tsx b/src/core/Indicators/index.tsx
index d7eb614daccc17c9f78af4ce67abba3bb6990856..753b2ae384b84c58658da16fa8e4893e66be2ee5 100644
--- a/src/core/Indicators/index.tsx
+++ b/src/core/Indicators/index.tsx
@@ -13,6 +13,8 @@ import { INDICATOR_COLOR_PALETTE_MIDDLE_INDEX } from '@src/theme';
 import { t } from '@src/translations';
 import { IndicatorConfig, LineCandle, SettingsValues } from '@src/types';
 
+import { gains, gainsSma, rs, rsi, rsiMa } from './rsi';
+
 export type ChartTypeToCandleData = {
   ['Bar']: Candle;
   ['Candlestick']: Candle;
@@ -52,6 +54,7 @@ export const indicatorsMap = (): Partial<Record<IndicatorsIds, IndicatorConfig>>
       },
     ],
   },
+
   [IndicatorsIds.SMA]: {
     series: [
       {
@@ -78,6 +81,7 @@ export const indicatorsMap = (): Partial<Record<IndicatorsIds, IndicatorConfig>>
       { type: 'number', key: 'offset', label: t('Offset'), defaultValue: 0, min: -100, max: 100 },
     ],
   },
+
   [IndicatorsIds.EMA]: {
     paletteStartIndex: INDICATOR_COLOR_PALETTE_MIDDLE_INDEX,
     series: [
@@ -205,4 +209,120 @@ export const indicatorsMap = (): Partial<Record<IndicatorsIds, IndicatorConfig>>
       },
     ],
   },
+
+  [IndicatorsIds.RSI]: {
+    newPane: true,
+    series: [
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'gains',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => gains(params as IndicatorDataFormatter<'Line'>, true),
+        seriesOptions: {
+          visible: false,
+          lastValueVisible: false,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'losses',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => gains(params as IndicatorDataFormatter<'Line'>, false),
+        seriesOptions: {
+          visible: false,
+          lastValueVisible: false,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'gains_sma',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => gainsSma(params as IndicatorDataFormatter<'Line'>, 'gains'),
+        seriesOptions: {
+          visible: false,
+          lastValueVisible: false,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'losses_sma',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => gainsSma(params as IndicatorDataFormatter<'Line'>, 'losses'),
+        seriesOptions: {
+          visible: false,
+          lastValueVisible: false,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'RS',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => rs(params as IndicatorDataFormatter<'Line'>),
+        seriesOptions: {
+          visible: false,
+          lastValueVisible: false,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'RSI',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => rsi(params as IndicatorDataFormatter<'Line'>),
+        seriesOptions: {
+          visible: true,
+          lastValueVisible: true,
+        },
+      },
+      {
+        name: 'Line', // todo: change with enum
+        actLikeMainSerie: true,
+        id: 'RSI_ma',
+        priceScaleOptions: {
+          mode: PriceScaleMode.Normal,
+        },
+        dataFormatter: (params) => rsiMa(params as IndicatorDataFormatter<'Line'>),
+        seriesOptions: {
+          visible: true,
+          lastValueVisible: true,
+        },
+        onSerieInit: (series) => {
+          if (series) {
+            series.createPriceLine({
+              price: 70,
+              color: '#787b86',
+              lineWidth: 1,
+              lineStyle: 2, // dashed line
+              axisLabelVisible: false,
+            });
+            series.createPriceLine({
+              price: 30,
+              color: '#787b86',
+              lineWidth: 1,
+              lineStyle: 2, // dashed line
+              axisLabelVisible: false,
+            });
+          }
+        },
+      },
+    ],
+    settings: [{ type: 'number', key: 'MA_Length', label: t('MA length'), defaultValue: 14, min: 5, max: 500 }],
+  },
 });
diff --git a/src/core/Indicators/rma.ts b/src/core/Indicators/rma.ts
new file mode 100644
index 0000000000000000000000000000000000000000..955294ef93c92aa50fe458b8cbedcbc46f3f71cc
--- /dev/null
+++ b/src/core/Indicators/rma.ts
@@ -0,0 +1,69 @@
+import { SeriesDataItemTypeMap, Time } from 'lightweight-charts';
+
+import { LineCandle } from '@src/types';
+
+import { isLineData } from '@src/utils';
+
+import { SerieData } from '../Series/BaseSeries';
+
+import { ChartTypeToCandleData } from '.';
+
+export function calculatePreciseRMASeriesData(
+  selfData: SeriesDataItemTypeMap<Time>['Line'][],
+  candle: ChartTypeToCandleData['Line'],
+  rmaLength: number,
+): SeriesDataItemTypeMap<Time>['Line'] {
+  const alpha = 1 / rmaLength;
+  const prevLineData = selfData.at(-1);
+
+  if (!prevLineData || !isLineData(prevLineData)) {
+    return {
+      time: candle.time as Time,
+    };
+  }
+
+  return {
+    time: candle.time as Time,
+    value: alpha * candle.value + (1 - alpha) * prevLineData.value,
+  };
+}
+
+export function calculateRMASeriesData(
+  candleData: LineCandle[],
+  rmaLength: number,
+): SeriesDataItemTypeMap<Time>['Line'][] {
+  const rma: SeriesDataItemTypeMap<Time>['Line'][] = candleData.map((lineCandle) => ({
+    time: lineCandle.time as Time,
+  }));
+  const alpha = 1 / rmaLength;
+  if (candleData.length < rmaLength) {
+    return rma;
+  }
+
+  let sum = 0;
+
+  for (let i = 0; i < rmaLength; i++) {
+    sum += candleData[i].value;
+  }
+
+  rma[rmaLength - 1] = {
+    ...rma[rmaLength - 1],
+    value: sum / rmaLength,
+  };
+
+  for (let i = rmaLength; i < candleData.length; i++) {
+    const candle = candleData[i];
+    const prevRMA = rma[i - 1];
+
+    if (!isLineData(prevRMA)) {
+      continue;
+    }
+
+    rma[i] = {
+      ...rma[i],
+      value: alpha * candle.value + (1 - alpha) * prevRMA.value,
+    };
+  }
+
+  return rma;
+}
diff --git a/src/core/Indicators/rsi.ts b/src/core/Indicators/rsi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bcac80088f43ea321925e456ce447447bab407d6
--- /dev/null
+++ b/src/core/Indicators/rsi.ts
@@ -0,0 +1,235 @@
+import { SeriesDataItemTypeMap, Time, WhitespaceData } from 'lightweight-charts';
+
+import { LineCandle } from '@src/types';
+import { isLineData } from '@src/utils';
+
+import { calculatePreciseRMASeriesData, calculateRMASeriesData } from './rma';
+import { calculateMASeriesData, calculatePreciseMASeriesData } from './sma';
+
+import { ChartTypeToCandleData, IndicatorDataFormatter } from '.';
+
+export function gains(
+  { candle, mainSeriesData, selfData }: IndicatorDataFormatter<'Line'>,
+  isGain: boolean,
+): SeriesDataItemTypeMap<Time>['Line'][] {
+  if (!mainSeriesData) return [];
+
+  if (!candle) {
+    const res: SeriesDataItemTypeMap<Time>['Line'][] = mainSeriesData.map((lineCandle) => ({ time: lineCandle.time }));
+
+    mainSeriesData.forEach((bar, index, self) => {
+      const change: number = bar.customValues.close - (self[index - 1]?.customValues.close ?? 0);
+
+      if ((isGain && change >= 0) || (!isGain && change < 0)) {
+        res[index] = {
+          time: bar.time,
+          value: change,
+        };
+      } else {
+        res[index] = {
+          time: bar.time,
+          value: 0,
+        };
+      }
+    });
+    return res;
+  }
+
+  const prevCandleClose =
+    mainSeriesData.at(-1)?.time === candle.time
+      ? mainSeriesData.at(-2)?.customValues.close
+      : mainSeriesData.at(-1)?.customValues.close;
+
+  if (!prevCandleClose) {
+    return [
+      {
+        time: candle.time,
+      },
+    ];
+  }
+
+  const change: number = candle.customValues.close - prevCandleClose;
+
+  if ((isGain && change >= 0) || (!isGain && change < 0)) {
+    return [
+      {
+        time: candle.time as Time,
+        value: change,
+      },
+    ];
+  }
+  return [
+    {
+      time: candle.time,
+      value: 0,
+    },
+  ];
+}
+
+export function gainsSma(
+  { candle, indicatorReference, settings, selfData }: IndicatorDataFormatter<'Line'>,
+  basedOn: string,
+): SeriesDataItemTypeMap<Time>['Line'][] {
+  if (!indicatorReference) return [];
+  const signalLength = typeof settings?.MA_Length === 'number' ? settings.MA_Length : 14;
+
+  const gainsSerie = (indicatorReference.getSeriesMap().get(basedOn)?.data() ??
+    []) as unknown as ChartTypeToCandleData['Line'][];
+
+  if (!candle) {
+    return calculateRMASeriesData(gainsSerie, signalLength);
+  }
+  const lastGains = gainsSerie.at(-1)?.time === candle.time ? gainsSerie.at(-1) : undefined;
+  if (!lastGains) {
+    return [
+      {
+        time: candle.time,
+      },
+    ];
+  }
+
+  return [calculatePreciseRMASeriesData(selfData as SeriesDataItemTypeMap<Time>['Line'][], lastGains, signalLength)];
+}
+
+export function rs(params: IndicatorDataFormatter<'Line'>): SeriesDataItemTypeMap<Time>['Line'][] {
+  const { candle, indicatorReference, settings, mainSeriesData } = params;
+  const signalLength = typeof settings?.MA_Length === 'number' ? settings.MA_Length : 14;
+
+  if (!indicatorReference) return [];
+  if (!mainSeriesData) return [];
+  const avgGains = (indicatorReference.getSeriesMap().get('gains_sma')?.data() ??
+    []) as unknown as SeriesDataItemTypeMap['Line'][];
+  const avgLosses = (indicatorReference.getSeriesMap().get('losses_sma')?.data() ??
+    []) as unknown as SeriesDataItemTypeMap['Line'][];
+
+  const rsSeries: SeriesDataItemTypeMap<Time>['Line'][] = mainSeriesData.map((lineCandle) => ({
+    time: lineCandle.time,
+  }));
+
+  if (mainSeriesData.length <= signalLength) return rsSeries;
+
+  const infinity = Number.MIN_SAFE_INTEGER;
+
+  if (!candle) {
+    for (let i = signalLength; i < mainSeriesData.length; i++) {
+      const avgGain = avgGains[i - signalLength];
+      const avgLoss = avgLosses[i - signalLength];
+
+      if (!isLineData(avgGain) || !isLineData(avgLoss)) {
+        continue;
+      }
+
+      if (avgLoss.value === 0) {
+        rsSeries[i] = {
+          time: avgGain.time as Time,
+          value: avgGain.value === 0 ? 1 : infinity,
+        };
+      } else {
+        rsSeries[i] = {
+          time: avgGain.time as Time,
+          value: avgGain.value / Math.abs(avgLoss.value),
+        };
+      }
+    }
+
+    return rsSeries.map((bar) => {
+      if (isLineData(bar)) {
+        return {
+          ...bar,
+          value: +bar.value.toFixed(2),
+        };
+      }
+      return bar;
+    });
+  }
+
+  const avgGain = avgGains.at(-1)?.time === candle.time ? avgGains.at(-1) : undefined;
+  const avgLoss = avgLosses.at(-1)?.time === candle.time ? avgLosses.at(-1) : undefined;
+
+  if (
+    !avgGain ||
+    !isLineData(avgGain) ||
+    !avgLoss ||
+    !isLineData(avgLoss) ||
+    avgGain.time !== candle.time ||
+    avgLoss.time !== candle.time
+  ) {
+    return [{ time: candle.time }];
+  }
+
+  if (avgLoss.value === 0) {
+    return [
+      {
+        time: avgGain.time as Time,
+        value: avgGain.value === 0 ? 1 : infinity,
+      },
+    ];
+  }
+  return [
+    {
+      time: avgGain.time as Time,
+      value: avgGain.value / Math.abs(avgLoss.value),
+    },
+  ];
+}
+
+export function rsi({
+  candle,
+  indicatorReference,
+}: IndicatorDataFormatter<'Line'>): SeriesDataItemTypeMap<Time>['Line'][] {
+  if (!indicatorReference) return [];
+
+  const rsSerie = (indicatorReference.getSeriesMap().get('RS')?.data() ??
+    []) as unknown as SeriesDataItemTypeMap['Line'][];
+
+  if (!candle) {
+    const res: SeriesDataItemTypeMap<Time>['Line'][] = [];
+    rsSerie.forEach((lineCandle) => {
+      if (!isLineData(lineCandle)) {
+        res.push({
+          time: lineCandle.time,
+        });
+      } else {
+        res.push({
+          ...lineCandle,
+          value: +(100 - 100 / (1 + lineCandle.value)).toFixed(2),
+        });
+      }
+    });
+
+    return res;
+  }
+
+  const lineCandle = rsSerie.at(-1)?.time === candle.time ? rsSerie.at(-1) : undefined;
+
+  if (!lineCandle || !isLineData(lineCandle) || lineCandle.time !== candle.time) {
+    return [{ time: candle.time }];
+  }
+
+  return [
+    {
+      ...lineCandle,
+      value: +(100 - 100 / (1 + lineCandle.value)).toFixed(2),
+    },
+  ];
+}
+
+export function rsiMa({
+  candle,
+  indicatorReference,
+  settings,
+}: IndicatorDataFormatter<'Line'>): SeriesDataItemTypeMap<Time>['Line'][] {
+  const signalLength = typeof settings?.MA_Length === 'number' ? settings.MA_Length : 14;
+
+  if (!indicatorReference) {
+    return [];
+  }
+
+  const rsiSerie = (indicatorReference.getSeriesMap().get('RSI')?.data() ?? []) as unknown as LineCandle[];
+
+  if (!candle) {
+    return calculateMASeriesData(rsiSerie, signalLength);
+  }
+
+  return [calculatePreciseMASeriesData(rsiSerie, candle, signalLength)];
+}
diff --git a/src/translations/russianDict.ts b/src/translations/russianDict.ts
index c9e375c6967ad81b105f902fa38aeb276e77f780..2259d0358d59ffab1db7d562b77257ecfdf73d57 100644
--- a/src/translations/russianDict.ts
+++ b/src/translations/russianDict.ts
@@ -27,7 +27,7 @@ export const russian = {
   week_few: 'недели',
   week_many: 'недель',
   month: 'месяц',
-  months: 'месяца',
+  months: 'месяцы',
   month_few: 'месяца',
   month_many: 'месяцев',
   Bars: 'Бары',
@@ -129,6 +129,7 @@ export const russian = {
   'Signal length': 'Длина сигнала',
   'Oscillator MA type': 'Тип осцилятора MA',
   'Signal MA type': 'Тип сигнала MA',
+  'MA length': 'Длина MA',
   Save: 'Сохранить',
   Reject: 'Отменить',
   Volume: 'Объём',
diff --git a/src/types/indicator.ts b/src/types/indicator.ts
index 036fd00f8357362b3f809ab866e3909e0dd154d8..26b504ebc8ce209d141ec8c6c4760f3380d034f8 100644
--- a/src/types/indicator.ts
+++ b/src/types/indicator.ts
@@ -13,6 +13,7 @@ import {
 
 import { indicatorLabelById, IndicatorsIds } from '@src/constants';
 import { IndicatorDataFormatter } from '@src/core/Indicators';
+import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
 import { ChartSeriesType } from '@src/types/chart';
 import { SettingField } from '@src/types/settings';
 import { SymbolInfoInput } from '@src/types/symbol';
@@ -38,6 +39,7 @@ export interface IndicatorSerie {
   name: ChartSeriesType;
   actLikeMainSerie?: boolean;
   seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
+  onSerieInit?: (series: SeriesStrategies) => void;
   priceScaleOptions?: DeepPartial<PriceScaleOptions>;
   priceScaleId?: string;
   dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
diff --git a/stories/TradeRadar/TradeRadar.stories.tsx b/stories/TradeRadar/TradeRadar.stories.tsx
index 854c8b2a4dc0a18a036fb16fb2090e03e4b562e2..ce6cfa363e4f2cedb021ec2ddf320d7581d6b295 100644
--- a/stories/TradeRadar/TradeRadar.stories.tsx
+++ b/stories/TradeRadar/TradeRadar.stories.tsx
@@ -146,11 +146,6 @@ const args: TRProps = {
               {
                 indicatorType: IndicatorsIds.EMA,
               },
-              {
-                symbolInfo: { symbolId: 'TQBR:SBER' },
-                scale: Direction.Right,
-                seriesName: 'Line',
-              },
             ],
             drawings: [],
           },
@@ -159,7 +154,7 @@ const args: TRProps = {
             id: 1,
             indicators: [
               {
-                indicatorType: IndicatorsIds.MACD,
+                indicatorType: IndicatorsIds.RSI,
               },
             ],
             drawings: [],