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


import { IChartApi } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';

import { indicatorLabelById, indicatorSeriesLabelById, IndicatorsIds } from '@src/constants';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, SettingsValues } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';

type IIndicator = DOMObject;

export interface IndicatorParams extends DOMObjectParams {
  mainSymbol$: Observable<string>;
  lwcChart: IChartApi;
  dataSource: DataSource;
  associatedPane: Pane;
  config: IndicatorConfig;
  type?: IndicatorsIds;
  chartOptions?: ChartTypeOptions;
}

export class Indicator extends DOMObject implements ISerializable<IndicatorSnapshot> {
  private indicatorType?: IndicatorsIds;
  private series: SeriesStrategies[] = [];
  private seriesMap: Map<string, SeriesStrategies> = new Map();
  private lwcChart: IChartApi;
  private dataSource: DataSource;
  private mainSymbol$: Observable<string>;
  private associatedPane: Pane;
  private config: IndicatorConfig;
  private settings: SettingsValues = {};

  private dataChangeHandlers = new Set<() => void>();
  private seriesSubscriptions = new Subscription();

  constructor({
    id,
    type,
    lwcChart,
    dataSource,
    zIndex,
    onDelete,
    moveUp,
    moveDown,
    mainSymbol$,
    associatedPane,
    paneId,
    config,
  }: IndicatorParams) {
    super({ id, name: config?.label ?? id, zIndex, onDelete, moveUp, moveDown, paneId });
    this.lwcChart = lwcChart;
    this.dataSource = dataSource;
    this.mainSymbol$ = mainSymbol$;
    this.indicatorType = type;
    this.config = config;
    this.name = this.getLabel();

    this.settings = this.getDefaultSettings();

    this.associatedPane = associatedPane;

    this.createSeries();

    this.associatedPane.setIndicator(this.id, this);
  }

  public subscribeDataChange(handler: () => void): Subscription {
    this.dataChangeHandlers.add(handler);

    return new Subscription(() => {
      this.dataChangeHandlers.delete(handler);
    });
  }

  public deletable = (): boolean => {
    return this.associatedPane.isMainPane() || this.associatedPane.isLast();
  };

  public getLabel = () => {
    if (this.config.label) {
      return this.config.label;
    }

    if (this.indicatorType) {
      return indicatorLabelById[this.indicatorType];
    }

    return this.id;
  };

  public getSeriesLabel(serieName: string): string | undefined {
    if (this.config.seriesLabels?.[serieName]) {
      return this.config.seriesLabels[serieName];
    }

    if (this.indicatorType) {
      return indicatorSeriesLabelById[this.indicatorType]?.[serieName];
    }

    return undefined;
  }

  public getId(): string {
    return this.id;
  }

  public getType(): IndicatorsIds | undefined {
    return this.indicatorType;
  }

  public getPane(): Pane {
    return this.associatedPane;
  }

  public getSeriesMap(): Map<string, SeriesStrategies> {
    return this.seriesMap;
  }

  public getSettings(): SettingsValues {
    return { ...this.settings };
  }

  public getSettingsConfig() {
    return this.config.settings ?? [];
  }

  public hasSettings(): boolean {
    return Boolean(this.config.settings?.length);
  }

  public updateSettings(settings: SettingsValues): void {
    this.settings = settings;

    // todo: обновлять данные серий без удаления и повторного создания
    this.destroySeries();
    this.createSeries();

    this.notifyDataChanged();
  }

  public show() {
    this.series.forEach((s) => {
      s.show();
    });
    super.show();
  }

  public hide() {
    this.series.forEach((s) => {
      s.hide();
    });
    super.hide();
  }

  public override getSnapshot(): DOMObjectSnapshot & IndicatorSnapshot {
    const domSnap = super.getSnapshot();

    return {
      ...domSnap,
      dataSource: this.dataSource,
      indicatorType: this.indicatorType,
      config: this.config,
    };
  }

  public setSnapshot(snap: IndicatorSnapshot): void {}

  // destroy и delete принципиально отличаются!
  // delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
  public delete() {
    super.delete();
  }

  // destroy и delete принципиально отличаются!
  public destroy() {
    this.destroySeries();
    this.dataChangeHandlers.clear();
    this.associatedPane.removeIndicator(this.id);
  }

  private createSeries(): void {
    this.config.series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
      const serie = SeriesFactory.create(name!)({
        lwcChart: this.lwcChart,
        dataSource: this.dataSource,
        customFormatter: dataFormatter
          ? (params) =>
              dataFormatter({
                ...params,
                settings: this.settings,
                indicatorReference: this,
              })
          : undefined,
        seriesOptions,
        priceScaleOptions,
        mainSymbol$: this.mainSymbol$,
        mainSerie$: this.associatedPane.getMainSerie(),
        showSymbolLabel: false,
        paneIndex: this.associatedPane.getId(),
        indicatorReference: this,
      });

      const handleDataChanged = () => {
        this.notifyDataChanged();
      };

      serie.subscribeDataChanged(handleDataChanged);

      this.seriesSubscriptions.add(() => {
        serie.unsubscribeDataChanged(handleDataChanged);
      });

      this.seriesMap.set(serieId, serie);

      this.series.push(serie);
    });
  }

  private destroySeries(): void {
    this.seriesSubscriptions.unsubscribe();
    this.seriesSubscriptions = new Subscription();

    this.series.forEach((s) => {
      s.destroy();
    });

    this.series = [];
    this.seriesMap.clear();
  }

  private notifyDataChanged(): void {
    this.dataChangeHandlers.forEach((handler) => {
      handler();
    });
  }

  private getDefaultSettings(): SettingsValues {
    const settings: SettingsValues = {};

    this.config.settings?.forEach((field) => {
      settings[field.key] = field.defaultValue;
    });

    return settings;
  }
}



import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';

interface SeriesParams {
  eventManager: EventManager;
  dataSource: DataSource;
  lwcChart: IChartApi;
  paneManager: PaneManager;
  DOM: DOMModel;
  initialIndicators?: IndicatorSnapshot[];
  chartOptions?: ChartTypeOptions;
}

export class IndicatorManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private chartOptions?: ChartTypeOptions;

  private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
  private indicatorsMap$: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject(new Map()); // todo: заменить IndicatorsIds ключ на уникальный id индикатора
  private DOM: DOMModel;
  private dataSource: DataSource;
  private paneManager: PaneManager;

  constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, initialIndicators, paneManager }: SeriesParams) {
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.chartOptions = chartOptions;
    this.DOM = DOM;
    this.dataSource = dataSource;
    this.paneManager = paneManager;

    this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());

    initialIndicators?.forEach((ind) => {
      this.addIndicator(ind);
    });
  }

  public addEntity<T extends Indicator>(
    factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
  ): T {
    return this.DOM.setEntity(factory);
  }

  public addIndicator(snap: Partial<IndicatorSnapshot>): void {
    if (!snap.indicatorType) {
      console.error('[IndicatorManager] Не был получен тип индиктора');
      return;
    }
    const indicatorsMap = new Map(this.indicatorsMap$.value);

    const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;

    const config = snap.config ?? (indicatorsConfigMap[snap.indicatorType] as IndicatorConfig);

    const associatedPane =
      snap.paneId !== undefined
        ? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
        : config?.newPane
          ? this.paneManager.addPane()
          : this.paneManager.getMainPane();

    const indicatorToSet = this.addEntity<Indicator>(
      (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
        new Indicator({
          id,
          paneId: associatedPane.getId(),
          zIndex,
          onDelete: this.deleteIndicator,
          moveUp,
          moveDown,

          mainSymbol$: this.eventManager.getSymbol(),
          lwcChart: this.lwcChart,
          dataSource: this.dataSource,
          associatedPane,
          config,
          type: snap.indicatorType,
          chartOptions: this.chartOptions,
        }),
    );

    indicatorsMap.set(id, indicatorToSet);

    this.indicatorsMap$.next(indicatorsMap);
    this.entities$.next(Array.from(indicatorsMap.values()));
  }

  public getIndicators() {
    return this.indicatorsMap$.pipe(map((indicators) => Array.from(indicators.keys())));
  }

  public removeEntity(entity: DOMObject): void {
    this.DOM.removeEntity(entity);
  }

  public deleteIndicator = (id: string) => {
    const indicatorsMap = new Map(this.indicatorsMap$.value);
    const entity = indicatorsMap.get(id);

    if (!entity) {
      return;
    }

    this.removeEntity(entity);
    entity.destroy();
    indicatorsMap.delete(id);

    this.indicatorsMap$.next(indicatorsMap);
    this.entities$.next(Array.from(indicatorsMap.values()));
  };

  public entities(): Observable<Indicator[]> {
    return this.entities$.asObservable();
  }
}


import { PriceScaleMode, SeriesType } from 'lightweight-charts';

import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdOscillatorFastMa, macdOscillatorSlowMa, macdSignal } from '@core/Indicators/macd';
import { smaIndicator } from '@core/Indicators/sma';
import { volume } from '@core/Indicators/volume';
import { SerieData } from '@core/Series/BaseSeries';

import { Candle } from '@lib';
import { IndicatorsIds } from '@src/constants';
import { getThemeStore } from '@src/theme';
import { Direction, IndicatorConfig, LineCandle, SettingsValues } from '@src/types';

export type ChartTypeToCandleData = {
  ['Bar']: Candle;
  ['Candlestick']: Candle;
  ['Area']: LineCandle;
  ['Baseline']: LineCandle;
  ['Line']: LineCandle;
  ['Histogram']: LineCandle;
  ['Custom']: Candle;
};

export interface IndicatorDataFormatter<T extends SeriesType> {
  mainSeriesData: SerieData[];
  selfData: ChartTypeToCandleData[T][];
  candle?: SerieData;
  indicatorReference?: Indicator;
  settings?: SettingsValues;
}

export function applyIndicatorColor(config: IndicatorConfig, color: string): IndicatorConfig {
  return {
    ...config,
    series: config.series.map((series) => {
      const isVisibleLine = series.name === 'Line' && series.seriesOptions?.visible !== false;

      if (!isVisibleLine) {
        return series;
      }

      return {
        ...series,
        seriesOptions: {
          ...series.seriesOptions,
          color,
        },
      };
    }),
  };
}

export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
  [IndicatorsIds.Volume]: {
    series: [
      {
        name: 'Histogram',
        id: 'volume',
        priceScaleOptions: {
          scaleMargins: { top: 0.7, bottom: 0 },
        },
        seriesOptions: {
          priceScaleId: 'vol',
          priceFormat: {
            type: 'volume',
          },
        },
        dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
  },
  [IndicatorsIds.SMA]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'sma',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineSma,
        },
        dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>),
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1, max: 500 },
      {
        type: 'select',
        key: 'source',
        label: 'Данные',
        defaultValue: 'close',
        options: [
          { label: 'Цена открытия', value: 'open' },
          { label: 'Максимум', value: 'high' },
          { label: 'Минимум', value: 'low' },
          { label: 'Цена закрытия', value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
    ],
  },
  [IndicatorsIds.EMA]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'ema',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineEma,
        },
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>);
        },
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1, max: 500 },
      {
        type: 'select',
        key: 'source',
        label: 'Данные',
        defaultValue: 'close',
        options: [
          { label: 'Цена открытия', value: 'open' },
          { label: 'Максимум', value: 'high' },
          { label: 'Минимум', value: 'low' },
          { label: 'Цена закрытия', value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
    ],
  },

  [IndicatorsIds.MACD]: {
    newPane: true,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'oscillatorSlowMa',
        dataFormatter: (params) => macdOscillatorSlowMa(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: 'macd_oscillator_ma',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'oscillatorFastMa',
        dataFormatter: (params) => macdOscillatorFastMa(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: 'macd_oscillator_ma',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'macdLine',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdLine(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.indicatorLineSma,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'signalLine',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdSignal(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.chartCandleWickUp,
        },
      },
      {
        name: 'Histogram', // todo: change with enum
        id: 'histogram',
        priceScaleOptions: {
          autoScale: true,
        },
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
        },
        dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
    settings: [
      {
        type: 'select',
        key: 'source',
        label: 'Данные',
        defaultValue: 'close',
        options: [
          { label: 'Цена открытия', value: 'open' },
          { label: 'Максимум', value: 'high' },
          { label: 'Минимум', value: 'low' },
          { label: 'Цена закрытия', value: 'close' },
        ],
      },
      { type: 'number', key: 'fastLength', label: 'Длина Fast', defaultValue: 12, min: 1, max: 500 },
      { type: 'number', key: 'slowLength', label: 'Длина Slow', defaultValue: 26, min: 1, max: 500 },
      { type: 'number', key: 'signalLength', label: 'Signal length', defaultValue: 9, min: 1, max: 500 },
      {
        type: 'select',
        key: 'oscillatorMaType',
        label: 'Oscillator MA type',
        defaultValue: 'ema',
        options: [
          { label: 'EMA', value: 'ema' },
          { label: 'SMA', value: 'sma' },
        ],
      },
      {
        type: 'select',
        key: 'signalMaType',
        label: 'Signal MA type',
        defaultValue: 'ema',
        options: [
          { label: 'EMA', value: 'ema' },
          { label: 'SMA', value: 'sma' },
        ],
      },
    ],
  },
};


import {
  DeepPartial,
  HistogramData,
  LineData,
  LineWidth,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
  WhitespaceData,
} from 'lightweight-charts';

import { indicatorLabelById, IndicatorsIds } from '@src/constants';
import { IndicatorDataFormatter } from '@src/core/Indicators';
import { ChartSeriesType } from '@src/types/chart';
import { SettingField } from '@src/types/settings';

export type IndicatorData = HistogramData<Time> | LineData<Time> | WhitespaceData<Time>;

export type IndicatorType = 'SMA' | 'EMA' | 'RSI' | 'OHLC' | 'VOL';

export type MASource = 'open' | 'high' | 'low' | 'close';

export type IndicatorLabel = (typeof indicatorLabelById)[IndicatorsIds];

export interface IndicatorStateConfig {
  type: IndicatorType;
  id?: IndicatorsIds;
  period?: number;
  color?: string;
  lineWidth?: LineWidth;
  visible?: boolean;
  params?: Record<string, any>;
  pane?: string;
}

export interface IndicatorSerie {
  id: string;
  name: ChartSeriesType;
  seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  priceScaleId?: string;
  dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
}

export interface IndicatorConfig {
  series: IndicatorSerie[];
  settings?: SettingField[];
  newPane?: boolean;
  label?: string;
  seriesLabels?: Record<string, string>;
}