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


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 { t } from '@src/translations';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatCompactNumber, 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: formatCompactNumber(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatCompactNumber(absoluteChange ?? 0),
        name: t('Change'),
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatCompactNumber(percentageChange)}%` : '',
        name: t('Change'),
        color,
      },
      time: {
        value: time,
        name: t('Time'),
        color,
      },
    };
  }
}



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 { 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 { INDICATOR_COLOR_PALETTE_MIDDLE_INDEX } from '@src/theme';
import { t } from '@src/translations';
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 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: {},
        dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>),
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: t('Length'), defaultValue: 10, min: 1, max: 500 },
      {
        type: 'select',
        key: 'source',
        label: t('Data'),
        defaultValue: 'close',
        options: [
          { label: t('Open price'), value: 'open' },
          { label: t('Max'), value: 'high' },
          { label: t('Min'), value: 'low' },
          { label: t('Close price'), value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: t('Offset'), defaultValue: 0, min: -100, max: 100 },
    ],
  },
  [IndicatorsIds.EMA]: {
    paletteStartIndex: INDICATOR_COLOR_PALETTE_MIDDLE_INDEX,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'ema',
        seriesOptions: {},
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>);
        },
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: t('Length'), defaultValue: 10, min: 1, max: 500 },
      {
        type: 'select',
        key: 'source',
        label: t('Data'),
        defaultValue: 'close',
        options: [
          { label: t('Open price'), value: 'open' },
          { label: t('Max'), value: 'high' },
          { label: t('Min'), value: 'low' },
          { label: t('Close price'), value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: t('Offset'), 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,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'oscillatorFastMa',
        dataFormatter: (params) => macdOscillatorFastMa(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: 'macd_oscillator_ma',
          visible: false,
          lastValueVisible: false,
        },
      },
      {
        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,
        },
      },
      {
        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,
        },
      },
      {
        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: t('Data'),
        defaultValue: 'close',
        options: [
          { label: t('Open price'), value: 'open' },
          { label: t('Max'), value: 'high' },
          { label: t('Min'), value: 'low' },
          { label: t('Close price'), value: 'close' },
        ],
      },
      { type: 'number', key: 'fastLength', label: t('Fast length'), defaultValue: 12, min: 1, max: 500 },
      { type: 'number', key: 'slowLength', label: t('Slow length'), defaultValue: 26, min: 1, max: 500 },
      { type: 'number', key: 'signalLength', label: t('Signal length'), defaultValue: 9, min: 1, max: 500 },
      {
        type: 'select',
        key: 'oscillatorMaType',
        label: t('Oscillator MA type'),
        defaultValue: 'ema',
        options: [
          { label: 'EMA', value: 'ema' },
          { label: 'SMA', value: 'sma' },
        ],
      },
      {
        type: 'select',
        key: 'signalMaType',
        label: t('Signal MA type'),
        defaultValue: 'ema',
        options: [
          { label: 'EMA', value: 'ema' },
          { label: 'SMA', value: 'sma' },
        ],
      },
    ],
  },
});


import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';

import { MAIN_PANE_INDEX } from '@src/constants';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { getPaletteColorFromIndex, normalizeColor, normalizeSymbol } from '@src/utils';

interface CompareEntry {
  key: string;
  symbol: string;
  mode: CompareMode;
  paneIndex: number;
  symbol$: BehaviorSubject<string>;
  entity: Indicator;
}

function makeKey(symbol: string, mode: CompareMode): string {
  return `${symbol}|${mode}`;
}

interface CompareManagerParams {
  chart: IChartApi;
  eventManager: EventManager;
  dataSource: DataSource;
  indicatorManager: IndicatorManager;
  paneManager: PaneManager;
  initialIndicators?: IndicatorSnapshot[];
}

const getDefaultCompareIndicatorConfig = (symbol: string, usedColors: string[]): IndicatorConfig => {
  const reservedColors = new Set(usedColors.map(normalizeColor));

  return {
    newPane: true,
    label: symbol,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(reservedColors, 0),
        },
      },
    ],
  };
};

export class CompareManager {
  private readonly chart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly dataSource: DataSource;
  private readonly indicatorManager: IndicatorManager;
  private readonly paneManager: PaneManager;

  private readonly entries = new Map<string, CompareEntry>();
  private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
  private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);

  constructor({
    chart,
    eventManager,
    dataSource,
    indicatorManager,
    paneManager,
    initialIndicators = [],
  }: CompareManagerParams) {
    this.chart = chart;
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.indicatorManager = indicatorManager;
    this.paneManager = paneManager;

    this.eventManager.timeframe().subscribe(() => {
      this.applyPolicy();
    });

    if (!initialIndicators) {
      return;
    }

    this.setup(initialIndicators);
  }

  private async setup(initialIndicators: IndicatorSnapshot[]) {
    for (const indicator of initialIndicators) {
      if (indicator.config && indicator.config.label) {
        // условие проверки compare ли это
        const serie = indicator.config.series[0];

        const compareMode =
          serie.seriesOptions?.priceScaleId === Direction.Left
            ? CompareMode.NewScale
            : indicator.config.newPane
              ? CompareMode.NewPane
              : CompareMode.Percentage;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
      }
    }
  }

  public itemsObs(): Observable<CompareItem[]> {
    return this.itemsSubject.asObservable();
  }

  public clear(): void {
    const keys = Array.from(this.entries.keys());
    for (let i = 0; i < keys.length; i += 1) {
      this.removeByKey(keys[i]);
    }
    this.applyPolicy();
    this.publish();
  }

  public async setSymbolMode(
    seriesType: SeriesType,
    symbolRaw: string,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

    if (mode === CompareMode.NewScale && this.isNewScaleDisabled()) {
      return;
    }

    const key = makeKey(symbol, mode);
    if (this.entries.has(key)) return;

    const symbol$ = new BehaviorSubject(symbol);

    const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
      const usedColorsByCompare = this.entitiesSubject.value.map(
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        (ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
      );
      const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
      const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        ind.config?.series?.map((serie) => serie.seriesOptions?.color),
      );
      const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
      const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);

      const config = getDefaultCompareIndicatorConfig(symbol, usedColors);

      const associatedPane =
        mode === CompareMode.NewPane
          ? paneId !== undefined
            ? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
            : this.paneManager.addPane()
          : this.paneManager.getMainPane();

      return new Indicator({
        id: key,
        lwcChart: this.chart,
        mainSymbol$: symbol$,
        dataSource: this.dataSource,
        associatedPane,
        config: {
          ...config,
          series: [
            {
              ...config.series[0],
              seriesOptions: {
                ...config.series[0]?.seriesOptions,
                priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
              },
            },
          ],
          newPane: mode === CompareMode.NewPane,
        },
        zIndex,
        onDelete: () => this.removeByKey(key),
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

    this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });

    this.applyPolicy();
    this.publish();

    await this.dataSource.isReady(symbol);
  }

  public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

    this.removeByKey(makeKey(symbol, mode));
    this.applyPolicy();
    this.publish();
  }

  public removeSymbol(symbolRaw: string): void {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

    const all = Array.from(this.entries.entries());
    for (let i = 0; i < all.length; i += 1) {
      const [key, entry] = all[i];
      if (entry.symbol === symbol) this.removeByKey(key);
    }

    this.applyPolicy();
    this.publish();
  }

  public isNewScaleDisabled(): boolean {
    return this.itemsSubject.value.length > 0;
  }

  public isNewScaleDisabledObservable(): Observable<boolean> {
    return this.itemsSubject.pipe(
      map((items) => items.length > 0),
      distinctUntilChanged(),
    );
  }

  public getAllEntities() {
    return Array.from(this.entries.values()).map(({ symbol, entity, mode }) => ({
      symbol,
      entity,
      mode,
    }));
  }

  public destroy(): void {
    this.clear();
    this.itemsSubject.complete();
    this.entitiesSubject.complete();
  }

  private removeByKey(key: string): void {
    const entry = this.entries.get(key);
    if (!entry) return;

    this.entries.delete(key);
    this.indicatorManager.removeEntity(entry.entity);
    entry.entity.destroy();
    entry.symbol$.complete();

    this.applyPolicy();
    this.publish();
  }

  private publish(): void {
    const values = Array.from(this.entries.values());

    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

    for (let i = 0; i < values.length; i += 1) {
      items.push({ symbol: values[i].symbol, mode: values[i].mode });
      entities.push(values[i].entity);
    }

    this.itemsSubject.next(items);
    this.entitiesSubject.next(entities);
  }

  private applyPolicy(): void {
    const list = Array.from(this.entries.values());

    let percentEnabled = false;
    for (let i = 0; i < list.length; i += 1) {
      if (list[i].mode === CompareMode.Percentage) {
        percentEnabled = true;
        break;
      }
    }

    let hasMainNewScale = false;
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
      if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
        hasMainNewScale = true;
        break;
      }
    }

    const paneSet = new Set<number>();
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();

      if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
    }

    this.chart.applyOptions({
      leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
    });

    this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
      visible: true,
      mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
    });

    this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
      visible: hasMainNewScale,
      mode: PriceScaleMode.Normal,
      borderVisible: false,
    });

    const panes = Array.from(paneSet.values());
    for (let i = 0; i < panes.length; i += 1) {
      const paneIndex = panes[i];

      this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
        visible: true,
        mode: PriceScaleMode.Normal,
      });

      if (hasMainNewScale) {
        this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
          visible: true,
          mode: PriceScaleMode.Normal,
          borderVisible: false,
        });
      }
    }

    for (let i = 0; i < list.length; i += 1) {
      const entry = list[i];

      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const serie = Array.from(entry.entity.getSeriesMap())[0][1];
      const paneIndex = serie.getPane().paneIndex();

      if (paneIndex !== MAIN_PANE_INDEX) {
        serie.applyOptions({ priceScaleId: Direction.Right });
        continue;
      }

      serie.applyOptions({
        priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
      });
    }
  }
}