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


import type {
  BarData,
  CustomData,
  HistogramData,
  LineData,
  SeriesDataItemTypeMap,
  SingleValueData,
  Time,
} from 'lightweight-charts';

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

import type { ISeries } from '@src/modules/series-strategies';
import { t } from '@src/translations';
import type { Candle, LineCandle } from '@src/types';
import { formatCompactNumber } from '@src/utils';
import { isSingleValueData } from '@src/utils/isSingleValueData';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

type SingleValueSeriesType = 'Line' | 'Histogram' | 'Baseline';

type SourceData = Partial<Candle> & Partial<LineCandle>;

type LegendData =
  | BarData
  | LineData
  | HistogramData
  | CustomData
  | null;

type LegendValues = Partial<
  Record<
    keyof Ohlc,
    {
      value: number | string | Time;
      color: string;
      name: string;
    }
  >
>;

export abstract class SingleValueSeriesStrategy<TSeries extends SingleValueSeriesType>
  extends BaseSeries<TSeries>
  implements ISeries<TSeries>
{
  constructor(params: BaseSeriesParams<TSeries>) {
    super(params);

    this.subscribeDataSource(params.dataSource);
  }

  public validateData(data: SourceData[]): boolean {
    return Array.isArray(data) && data.every((point) => typeof point.time === '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]);

    if (!formattedData) {
      return;
    }

    this.updateRealtime(formattedData);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    return inputData.map((point) => this.formatPoint(point));
  }

  protected formatLegendValues(currentBar: LegendData, prevBar: LegendData): LegendValues {
    if (!isSingleValueData(currentBar)) {
      return {};
    }

    const change = calcCandleChange(prevBar, currentBar);

    if (!change) {
      return {};
    }

    const color = this.getLegendColor(currentBar);

    return {
      value: {
        value: formatCompactNumber(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatCompactNumber(change.absoluteChange ?? 0),
        name: t('Change'),
        color,
      },
      percentageChange: {
        value:
          change.percentageChange !== undefined
            ? `${formatCompactNumber(change.percentageChange)}%`
            : '',
        name: t('Change'),
        color,
      },
      time: {
        value: change.time,
        name: t('Time'),
        color,
      },
    };
  }

  protected getLegendColor(currentBar: SingleValueData<Time>): string {
    if ('color' in currentBar && typeof currentBar.color === 'string') {
      return removeAlphaFromHex(currentBar.color);
    }

    const options = this.options();

    if ('color' in options && typeof options.color === 'string') {
      return options.color;
    }

    return '';
  }

  private formatPoint(point: SourceData): SeriesDataItemTypeMap<Time>[TSeries] {
    const time = point.time as Time;
    const value = typeof point.close === 'number' ? point.close : point.value;

    if (typeof value !== 'number') {
      return {
        time,
      } as SeriesDataItemTypeMap<Time>[TSeries];
    }

    return {
      time,
      value,
      customValues: point as unknown as Record<string, unknown>,
    } as SeriesDataItemTypeMap<Time>[TSeries];
  }

  public abstract getTypeName(): string;
}

















import {
  LineSeries,
  LineStyle,
  type SeriesDefinition,
  type SeriesPartialOptionsMap,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

import { getThemeStore } from '@src/theme/store';

export class LineSeriesStrategy extends SingleValueSeriesStrategy<'Line'> {
  protected seriesDefinition(): SeriesDefinition<'Line'> {
    return LineSeries;
  }

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

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

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

















import {
  HistogramSeries,
  type SeriesDefinition,
  type SeriesPartialOptionsMap,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

export class HistogramSeriesStrategy extends SingleValueSeriesStrategy<'Histogram'> {
  protected seriesDefinition(): SeriesDefinition<'Histogram'> {
    return HistogramSeries;
  }

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

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


















import {
  BaselineSeries,
  type SeriesDefinition,
  type SeriesPartialOptionsMap,
  type SingleValueData,
  type Time,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

export class BaselineSeriesStrategy extends SingleValueSeriesStrategy<'Baseline'> {
  protected seriesDefinition(): SeriesDefinition<'Baseline'> {
    return BaselineSeries;
  }

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

  protected getLegendColor(currentBar: SingleValueData<Time>): string {
    const options = this.options();

    return currentBar.value >= options.baseValue.price
      ? options.topLineColor
      : options.bottomLineColor;
  }

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

















import type { SingleValueData, Time } from 'lightweight-charts';

export function isSingleValueData(data: unknown): data is SingleValueData<Time> {
  if (typeof data !== 'object' || data === null) {
    return false;
  }

  return 'time' in data && 'value' in data && typeof data.value === 'number';
}


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

import { MAIN_PANE_INDEX } from '@src/constants';
import { type Candle } from '@src/types';
import {
  formatPrice,
  getPricePrecisionStep,
  isBarData,
  normalizeSeriesData,
} from '@src/utils';
import { isSingleValueData } from '@src/utils/isSingleValueData';

import type { DataSource } from '@core/DataSource';
import type { Indicator } from '@core/Indicator';
import type { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import type { Ohlc } from '@core/Legend';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type {
  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';

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;
  mainSymbolId$: Observable<string>;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  actLikeMainSerie?: boolean;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

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

  protected lwcChart: IChartApi;
  protected mainSymbolId$: Observable<string>;
  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;

  public actLikeMainSerie = false;

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

    this.actLikeMainSerie = actLikeMainSerie ?? false;
    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    this.mainSymbolId$ = mainSymbolId$;
    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();
      const currentBar = seriesData[seriesData.length - 1];

      if (!currentBar) {
        return {};
      }

      return this.formatLegendValues(currentBar, seriesData[seriesData.length - 2] ?? null);
    }

    const currentBar = param.seriesData.get(this.lwcSeries) ?? null;
    const previousBar = param.logical === null ? null : this.dataByIndex(param.logical - 1);

    return this.formatLegendValues(currentBar, previousBar);
  };

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

  public hide(): void {
    this.lwcSeries.applyOptions({
      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 pop(count: number): SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.pop(count);
  }

  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 lastValueData(globalLast: boolean): ReturnType<ISeriesApi<TSeries>['lastValueData']> {
    return this.lwcSeries.lastValueData(globalLast);
  }

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

  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 = false,
  ): void {
    this.lwcSeries.update(bar, historicalUpdate);
  }

  protected updateRealtime(bar: SeriesDataItemTypeMap<Time>[TSeries]): void {
    const data = this.lwcSeries.data();
    const lastBar = data[data.length - 1];

    if (lastBar && compareTime(bar.time, lastBar.time) < 0) {
      return;
    }

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

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

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

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

    series.priceScale().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: BarData | LineData | HistogramData | CustomData | null,
    prevBar: BarData | LineData | HistogramData | CustomData | null,
  ): Partial<
    Record<
      keyof Ohlc,
      {
        value: number | string | Time;
        color: string;
        name: string;
      }
    >
  >;

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    if (!this.customFormatter) {
      return this.formatMainSerie(inputData);
    }

    const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
    const selfData = this.data() as unknown as ChartTypeToCandleData[TSeries][];

    if (inputData.length !== 1) {
      return this.customFormatter({
        mainSeriesData,
        selfData,
        indicatorReference: this.indicatorReference ?? undefined,
      });
    }

    const candle = this.formatMainSerie(inputData)[0];

    if (!candle) {
      return [];
    }

    return this.customFormatter({
      mainSeriesData,
      selfData,
      candle: candle as unknown as SerieData,
      indicatorReference: this.indicatorReference ?? undefined,
    });
  }

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

    this.lwcSeries.applyOptions({
      priceFormat: {
        type: 'custom',
        minMove,
        formatter: (price: number) => formatPrice(price) ?? String(price),
      },
    });

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          title: this.showSymbolLabel ? symbol : '',
        });
      }),
    );

    this.subscriptions.add(
      this.mainSymbolId$.pipe(distinctUntilChanged()).subscribe((symbolId) => {
        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

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

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

function compareTime(left: Time, right: Time): number {
  return toComparableTime(left) - toComparableTime(right);
}

function toComparableTime(time: Time): number {
  if (typeof time === 'number') {
    return time;
  }

  if (typeof time === 'string') {
    return Date.parse(time);
  }

  return Date.UTC(time.year, time.month - 1, time.day);
}

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: Number.isNaN(percentageChange) ? 0 : percentageChange,
    };
  }

  if (isSingleValueData(prev) && isSingleValueData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;
    const high = current.customValues?.high;
    const low = current.customValues?.low;

    return {
      time: current.time,
      value: current.value,
      high: typeof high === 'number' ? high : current.value,
      low: typeof low === 'number' ? low : current.value,
      absoluteChange,
      percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}