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


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(
      (candle) =>
        typeof candle.time === 'number' &&
        Number.isFinite(candle.time),
    )
    .map((candle) => ({
      ...candle,
      time:
        (candle.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 =
      param.logical === null
        ? null
        : this.dataByIndex(
            param.logical - 1,
          );

    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 > 0
        ? data[data.length - 1]
        : null;

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

      return;
    }

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

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

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

  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) ??
                String(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,
    unknown
  >;
}) | 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;
}