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


import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';

import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';

import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';

export type SeriesStrategies =
  | CandlestickSeriesStrategy
  | LineSeriesStrategy
  | HistogramSeriesStrategy
  | BarSeriesStrategy
  | ISeries<'Baseline'>
  | ISeries<'Area'>
  | ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
 * Реализует паттерн Factory для создания нужной стратегии по типу графика
 */

export class SeriesFactory {
  static create(
    type: ChartSeriesType,
  ):
    | ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
    | ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
    | ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
    | ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
    if (type === 'Candlestick') {
      return ((params) => new CandlestickSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Candlestick'>,
      ) => CandlestickSeriesStrategy;
    }
    if (type === 'Histogram') {
      return ((params) => new HistogramSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Histogram'>,
      ) => HistogramSeriesStrategy;
    }
    if (type === 'Line') {
      return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
    }
    if (type === 'Bar') {
      return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
    }
    throw new Error(`Unsupported chart type: ${type}`);
  }
}




import {
  BarData,
  BarSeries,
  CustomData,
  HistogramData,
  LineData,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@src/core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isBarData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class BarSeriesStrategy extends BaseSeries<'Bar'> implements ISeries<'Bar'> {
  constructor(params: BaseSeriesParams<'Bar'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Bar'> {
    return BarSeries;
  }

  getDefaultOptions(): SeriesPartialOptionsMap['Bar'] {
    const { colors } = getThemeStore();

    return {
      upColor: colors.chartCandleUp,
      downColor: colors.chartCandleDown,
    };
  }

  getTypeName(): string {
    return 'Bar';
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every(
      (point) =>
        typeof point.time === 'number' &&
        typeof point.open === 'number' &&
        typeof point.high === 'number' &&
        typeof point.low === 'number' &&
        typeof point.close === 'number',
    );
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Bar'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      open: point.open,
      high: point.high,
      low: point.low,
      close: point.close,
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
    if (!currentBar || !isBarData(currentBar)) {
      return {};
    }

    const { colors } = getThemeStore();

    const color = removeAlphaFromHex(
      currentBar.close < currentBar.open ? colors.chartCandleWickDown : colors.chartCandleWickUp,
    );

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    return {
      open: {
        value: formatPrice(currentBar.open) ?? '',
        name: 'Откр.',
        color,
      },
      high: {
        value: formatPrice(currentBar.high) ?? '',
        name: 'Макс.',
        color,
      },
      low: {
        value: formatPrice(currentBar.low) ?? '',
        name: 'Мин.',
        color,
      },
      close: {
        value: formatPrice(currentBar.close) ?? '',
        name: 'Закр.',
        color,
      },
      absoluteChange: {
        value: formatPrice(absoluteChange) ?? '',
        name: 'Изм.',
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
        name: 'Изм.',
        color,
      },
      time: {
        value: time,
        name: 'Время',
        color,
      },
    };
  }
}

import {
  BarData,
  BarSeries,
  CustomData,
  HistogramData,
  LineData,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@src/core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isBarData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class BarSeriesStrategy extends BaseSeries<'Bar'> implements ISeries<'Bar'> {
  constructor(params: BaseSeriesParams<'Bar'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Bar'> {
    return BarSeries;
  }

  getDefaultOptions(): SeriesPartialOptionsMap['Bar'] {
    const { colors } = getThemeStore();

    return {
      upColor: colors.chartCandleUp,
      downColor: colors.chartCandleDown,
    };
  }

  getTypeName(): string {
    return 'Bar';
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every(
      (point) =>
        typeof point.time === 'number' &&
        typeof point.open === 'number' &&
        typeof point.high === 'number' &&
        typeof point.low === 'number' &&
        typeof point.close === 'number',
    );
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Bar'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      open: point.open,
      high: point.high,
      low: point.low,
      close: point.close,
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
    if (!currentBar || !isBarData(currentBar)) {
      return {};
    }

    const { colors } = getThemeStore();

    const color = removeAlphaFromHex(
      currentBar.close < currentBar.open ? colors.chartCandleWickDown : colors.chartCandleWickUp,
    );

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    return {
      open: {
        value: formatPrice(currentBar.open) ?? '',
        name: 'Откр.',
        color,
      },
      high: {
        value: formatPrice(currentBar.high) ?? '',
        name: 'Макс.',
        color,
      },
      low: {
        value: formatPrice(currentBar.low) ?? '',
        name: 'Мин.',
        color,
      },
      close: {
        value: formatPrice(currentBar.close) ?? '',
        name: 'Закр.',
        color,
      },
      absoluteChange: {
        value: formatPrice(absoluteChange) ?? '',
        name: 'Изм.',
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
        name: 'Изм.',
        color,
      },
      time: {
        value: time,
        name: 'Время',
        color,
      },
    };
  }
}



import {
  BarData,
  CustomData,
  HistogramData,
  LineData,
  LineSeries,
  LineStyle,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';

import { ISeries } from '@src/modules/series-strategies';

import { getThemeStore } from '@src/theme/store';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isLineData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class LineSeriesStrategy extends BaseSeries<'Line'> implements ISeries<'Line'> {
  constructor(params: BaseSeriesParams<'Line'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Line'> {
    return LineSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
    const { colors } = getThemeStore();

    return {
      color: colors.chartLineColor,
      lineWidth: 2,
      lineStyle: LineStyle.Solid,
    };
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every(
      (point) => typeof point.time === 'number' && typeof point.close === 'number' && !Number.isNaN(point.close),
    );
  }

  public getTypeName(): string {
    return 'Line';
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Line'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      value: point.close, // Для line графика используем close как value
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
    if (!currentBar || !isLineData(currentBar)) {
      return {};
    }

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;

    return {
      value: {
        value: formatPrice(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatPrice(absoluteChange) ?? '',
        name: 'Изм.',
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
        name: 'Изм.',
        color,
      },
      time: {
        value: time,
        name: 'Время',
        color,
      },
    };
  }
}



import {
  BarData,
  CustomData,
  HistogramData,
  HistogramSeries,
  LineData,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';

import { ISeries } from '@src/modules/series-strategies';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isHistogramData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class HistogramSeriesStrategy extends BaseSeries<'Histogram'> implements ISeries<'Histogram'> {
  constructor(params: BaseSeriesParams<'Histogram'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Histogram'> {
    return HistogramSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Histogram'] {
    return {};
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every(
      (point) => typeof point.time === 'number' && typeof point.volume === 'number' && !Number.isNaN(point.volume),
    );
  }

  public getTypeName(): string {
    return 'Histogram';
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Histogram'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      value: point.close,
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
    if (!currentBar || !isHistogramData(currentBar)) {
      return {};
    }

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;

    return {
      value: {
        value: formatPrice(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatPrice(absoluteChange) ?? '',
        name: 'Изм.',
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
        name: 'Изм.',
        color,
      },
      time: {
        value: time,
        name: 'Время',
        color,
      },
    };
  }
}



import {
  BarData,
  CandlestickSeries,
  CustomData,
  HistogramData,
  LineData,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';

import { ISeries } from '@src/modules/series-strategies';

import { getThemeStore } from '@src/theme/store';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isBarData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class CandlestickSeriesStrategy extends BaseSeries<'Candlestick'> implements ISeries<'Candlestick'> {
  constructor(params: BaseSeriesParams<'Candlestick'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Candlestick'> {
    return CandlestickSeries;
  }

  public getDefaultOptions(): SeriesPartialOptionsMap['Candlestick'] {
    const { colors } = getThemeStore();

    return {
      upColor: colors.chartCandleUp,
      downColor: colors.chartCandleDown,
      borderVisible: false,
      wickUpColor: colors.chartCandleWickUp,
      wickDownColor: colors.chartCandleWickDown,
    };
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    // todo: should be private
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every((point) => {
      if (!point) {
        return false;
      }
      // Проверяем обязательные поля
      if (typeof point.time !== 'number' || typeof point.close !== 'number') {
        return false;
      }

      // Если указаны OHLC, проверяем их корректность
      if (point.open !== undefined && point.high !== undefined && point.low !== undefined) {
        return point.high >= Math.max(point.open, point.close) && point.low <= Math.min(point.open, point.close);
      }

      return true;
    });
  }

  public getTypeName(): string {
    return 'Candlestick';
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Candlestick'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      open: point.open,
      high: point.high,
      low: point.low,
      close: point.close,
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
    if (!currentBar || !isBarData(currentBar)) {
      return {};
    }

    const { colors } = getThemeStore();

    const color = removeAlphaFromHex(
      currentBar.close < currentBar.open ? colors.chartCandleWickDown : colors.chartCandleWickUp,
    );

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    return {
      open: {
        value: formatPrice(currentBar.open) ?? '',
        name: 'Откр.',
        color,
      },
      high: {
        value: formatPrice(currentBar.high) ?? '',
        name: 'Макс.',
        color,
      },
      low: {
        value: formatPrice(currentBar.low) ?? '',
        name: 'Мин.',
        color,
      },
      close: {
        value: formatPrice(currentBar.close) ?? '',
        name: 'Закр.',
        color,
      },
      absoluteChange: {
        value: formatPrice(absoluteChange) ?? '',
        name: 'Изм.',
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
        name: 'Изм.',
        color,
      },
      time: {
        value: time,
        name: 'Время',
        color,
      },
    };
  }
}



import {
  BarData,
  BarPrice,
  BarsInfo,
  Coordinate,
  CreatePriceLineOptions,
  CustomData,
  DataChangedHandler,
  DeepPartial,
  HistogramData,
  IChartApi,
  IPaneApi,
  IPriceFormatter,
  IPriceLine,
  IPriceScaleApi,
  IRange,
  ISeriesApi,
  ISeriesPrimitive,
  LineData,
  MismatchDirection,
  MouseEventParams,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesOptionsMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';

export interface SerieData {
  time: Time;
  customValues: Candle;
}

export interface CreateSeriesParams<TSeries extends SeriesType> {
  chart: IChartApi;
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  paneIndex?: number;
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}

export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
  getLwcSeries: () => ISeriesApi<TSeries>;
  getLegendData: (
    param?: MouseEventParams,
  ) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}

export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
  lwcChart: IChartApi;
  dataSource: DataSource;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
  // todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
  const offsetMinutes = -180; // utc.time - moscow.time in minutes
  const secondsInMinute = 60;

  return candles
    .filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
    .map((c) => ({
      ...c,
      time: (c.time as number) - offsetMinutes * secondsInMinute,
    }));
}

export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
  protected lwcSeries: ISeriesApi<TSeries>;
  protected customFormatter:
    | undefined
    | ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);

  protected lwcChart: IChartApi;
  protected mainSymbol$: Observable<string>;
  protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  protected paneIndex: number | null = null;
  protected indicatorReference: Indicator | null = null;
  protected showSymbolLabel: boolean;

  private subscriptions = new Subscription();
  private dataSub: Subscription | null = null;
  private realtimeSub: Subscription | null = null;

  constructor({
    lwcChart,
    mainSymbol$,
    mainSerie$,
    customFormatter,
    seriesOptions,
    priceScaleOptions,
    showSymbolLabel = true,
    paneIndex,
    indicatorReference,
  }: BaseSeriesParams<TSeries>) {
    this.lwcSeries = this.createSeries({
      chart: lwcChart,
      seriesOptions,
      paneIndex,
      priceScaleOptions,
    });
    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    this.mainSymbol$ = mainSymbol$;
    this.mainSerie$ = mainSerie$;
    this.showSymbolLabel = showSymbolLabel;
    this.indicatorReference = indicatorReference ?? null;
  }

  public getLegendData = (
    param?: MouseEventParams,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
    if (!param) {
      const seriesData = this.data();
      if (seriesData.length < 1) return {};

      const dataToFormat = seriesData[seriesData.length - 1];
      const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
      return this.formatLegendValues(dataToFormat, prevBarData);
    }

    const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
    const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;

    return this.formatLegendValues(dataToFormat, prevBarData);
  };

  public show(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
  }

  public hide(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
  }

  public isVisible(): boolean {
    return this.lwcSeries.options().visible;
  }

  public destroy(): void {
    this.dataSub?.unsubscribe();
    this.realtimeSub?.unsubscribe();
    this.subscriptions.unsubscribe();
    this.lwcChart.removeSeries(this.lwcSeries);
  }

  public getLwcSeries(): ISeriesApi<TSeries> {
    return this.lwcSeries;
  }

  public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
    this.lwcSeries.applyOptions(options);
  }

  public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.attachPrimitive(primitive);
  }

  public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
    return this.lwcSeries.barsInLogicalRange(range);
  }

  public coordinateToPrice(coordinate: number): BarPrice | null {
    return this.lwcSeries.coordinateToPrice(coordinate);
  }

  public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
    return this.lwcSeries.createPriceLine(options);
  }

  public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.data();
  }

  public dataByIndex(
    logicalIndex: number,
    mismatchDirection?: MismatchDirection,
  ): SeriesDataItemTypeMap<Time>[TSeries] | null {
    return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
  }

  public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.detachPrimitive(primitive);
  }

  public getPane(): IPaneApi<Time> {
    return this.lwcSeries.getPane();
  }

  public moveToPane(paneIndex: number): void {
    this.lwcSeries.moveToPane(paneIndex);
  }

  public options(): Readonly<SeriesOptionsMap[TSeries]> {
    return this.lwcSeries.options();
  }

  public priceFormatter(): IPriceFormatter {
    return this.lwcSeries.priceFormatter();
  }

  public priceLines(): IPriceLine[] {
    return this.lwcSeries.priceLines();
  }

  public priceScale(): IPriceScaleApi {
    return this.lwcSeries.priceScale();
  }

  public priceToCoordinate(price: number): Coordinate | null {
    return this.lwcSeries.priceToCoordinate(price);
  }

  public removePriceLine(line: IPriceLine): void {
    this.lwcSeries.removePriceLine(line);
  }

  public seriesOrder(): number {
    return this.lwcSeries.seriesOrder();
  }

  public seriesType(): TSeries {
    return this.lwcSeries.seriesType();
  }

  public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
    const normalizedData = normalizeSeriesData(data);
    this.lwcSeries.setData(normalizedData);
  }

  public setSeriesOrder(order: number): void {
    this.lwcSeries.setSeriesOrder(order);
  }

  public subscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.subscribeDataChanged(handler);
  }

  public unsubscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.unsubscribeDataChanged(handler);
  }

  public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
    const data = this.lwcSeries.data();
    const last = data.length ? data[data.length - 1] : null;

    if (!last) {
      this.lwcSeries.update(bar, false);
      return;
    }

    const lastTime = last.time;
    const nextTime = bar.time;

    const isHist =
      historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);

    this.lwcSeries.update(bar, isHist);
  }

  protected createSeries({
    chart,
    seriesOptions,
    paneIndex = MAIN_PANE_INDEX,
    priceScaleOptions = {},
  }: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
    this.paneIndex = paneIndex;

    const defaultOptions = this.getDefaultOptions();
    const mergedOptions = {
      ...defaultOptions,
      ...seriesOptions,
    };

    const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);

    chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);

    return series;
  }

  protected abstract dataSourceSubscription(next: Candle[]): void;
  protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
  protected abstract dataSourceRealtimeSubscription(next: Candle): void;
  protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
  protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
  protected abstract formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;

  protected applyTimezone(data: Candle[]): Candle[] {
    return applyMoscowTimezone(data);
  }

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    const formatter = this.customFormatter;

    const data = this.applyTimezone(inputData);

    if (formatter) {
      const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];

      if (data.length === 1) {
        return formatter({
          mainSeriesData,
          selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
          candle: this.formatMainSerie(data)[0] as unknown as SerieData,
          indicatorReference: this.indicatorReference ?? undefined,
        });
      }

      return formatter({
        mainSeriesData,
        indicatorReference: this.indicatorReference ?? undefined,
        selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
      });
    }

    return this.formatMainSerie(data);
  }

  protected subscribeDataSource = (dataSource: DataSource): void => {
    const minMove = getPricePrecisionStep();

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          // todo: на каждый апдейт dataSource сеттим options. Не оптимально
          title: this.showSymbolLabel ? symbol : '',
          priceFormat: {
            type: 'custom',
            minMove,
            formatter: (price: number) => formatPrice(price),
          },
        });

        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

        this.dataSub = dataSource.subscribe(symbol, (next) => {
          this.dataSourceSubscription(next);
        });

        this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
          this.dataSourceRealtimeSubscription(next);
        });
      }),
    );
  };
}

export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
  if (!current) {
    return null;
  }

  if (!prev) {
    return current;
  }

  if (isBarData(prev) && isBarData(current)) {
    const absoluteChange = current.close - prev.close;
    const percentageChange = ((current.close - prev.close) / prev.close) * 100;

    return {
      ...current,
      absoluteChange,
      percentageChange,
    };
  }

  if (isLineData(prev) && isLineData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;

    return {
      time: current.time,
      value: current.value,
      high: current.customValues?.high as number,
      low: current.customValues?.low as number,
      absoluteChange,
      percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}