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


diff --git a/CHANGELOG.md b/CHANGELOG.md
index b00275933571da78ff0b993b47aa6bd8a87df290..5cc7a9d6e073ace50bfa41efaed4a6ace1637e22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 - Исправлена работа режима «Продолжить в режиме рисования» для линейки
 - Добавлен быстрый запуск линейки с помощью Shift
+- Добавлен индикатор RSI
+- Исправлен баг, когда линия EMA уходила в будущее
 
 # 0.1.20
 
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 c232d4eb4fd9dbb102c3cde42070996fa73f0cb5..e51b250d9a2571bb3a4aa06b06f41fe8ceed655d 100644
--- a/src/core/Indicator.ts
+++ b/src/core/Indicator.ts
@@ -211,7 +211,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,
@@ -234,6 +234,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 1a61e79c863352811f8f3c8c756987f04555a4d0..befa104cc79326ab866ed79f3140441bf36a749c 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 { Direction, 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: [
@@ -209,4 +213,127 @@ 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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: {
+          priceScaleId: Direction.Right,
+          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: 12, min: 1, 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..1d18f2cdce9ad9beec12bdc5599649e68870df90
--- /dev/null
+++ b/src/core/Indicators/rsi.ts
@@ -0,0 +1,222 @@
+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 }: 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 - bar.customValues.open;
+      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 change: number = candle.customValues.close - candle.customValues.open;
+
+  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);
+  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);
+  const avgLoss = avgLosses.at(-1);
+
+  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);
+
+  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][];