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


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

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

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>;
  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;

  constructor({
    lwcChart,
    mainSymbolId$,
    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.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 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 {
    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?: boolean): void {
    const data = this.lwcSeries.data();
    const lastBar = data[data.length - 1];

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

    const isHistoricalUpdate =
      historicalUpdate ?? (typeof lastBar.time === 'number' && typeof bar.time === 'number' && bar.time < lastBar.time);

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

  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);
    chart.priceScale(options.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: 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);
        });
      }),
    );
  };
}

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 (isLineData(prev) && isLineData(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;
}




import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import localizedFormat from 'dayjs/plugin/localizedFormat';

import 'dayjs/locale/ru';

import { TickMarkType, UTCTimestamp } from 'lightweight-charts';

import { TimeFormat, Timeframes } from '@src/types';

// плагины для работы с локализацией
dayjs.extend(localizedFormat);
dayjs.extend(duration);

export enum DateFormat {
  DOW_Q_YY = 'Mon Q3 \'97',
  DOW_Q_YYYY = 'Mon Q3 1997',
  DOW_D_MMM_YY = 'Mon 29 Sep \'97',
  DOW_MMM_YY = 'Mon Sep \'97',
  DOW_MMM_D_YYYY = 'Mon Sep 29, 1997',
  DOW_MMM_YYYY = 'Mon Sep 1997',
  DOW_MMM_D = 'Mon Sep 29',
  DOW_D_MMM = 'Mon 29 Sep',
  DOW_YYYY_MM_DD_DASH = 'Mon 1997-09-29',
  DOW_YY_MM_DD_DASH = 'Mon 97-09-29',
  DOW_YY_MM_DD_SLASH = 'Mon 97/09/29',
  DOW_YYYY_MM_DD_SLASH = 'Mon 1997/09/29',
  DOW_DD_MM_YYYY_DASH = 'Mon 29-09-1997',
  DOW_DD_MM_YY_DASH = 'Mon 29-09-97',
  DOW_DD_MM_YY_SLASH = 'Mon 29/09/97',
  DOW_DD_MM_YYYY_SLASH = 'Mon 29/09/1997',
  DOW_MM_DD_YY_SLASH = 'Mon 09/29/97',
  DOW_MM_DD_YYYY_SLASH = 'Mon 09/29/1997',
  DD_MM_YYYY_HH_mm_ss = '09.29.1997 00:00:00',
}

export const dateFormatOptions = Object.entries(DateFormat).map(([_, value]) => ({
  label: value,
  value,
}));

const customTimeFormatter = (time: UTCTimestamp, timeFormat: string, locale: string) => {
  const d = dayjs.unix(time).locale(locale);

  if (timeFormat === '12h') {
    return d.format('h:mm:ss A'); // 12-часовой формат
  }

  return d.format('HH:mm:ss'); // 24-часовой формат
};

export function shouldShowTime(tf: Timeframes): boolean {
  return !(tf.endsWith('d') || tf.endsWith('w') || tf.endsWith('M') || tf.endsWith('М') || tf.endsWith('Y'));
}

/**
 * Форматирует UTC timestamp в локальном часовом поясе пользователя согласно выбранному формату.
 * @param time - UTCTimestamp (секунды)
 * @param format - Значение из enum DateFormat
 * @param timeFormat - Значение из enum TimeFormat
 * @param showTime - Отображать ли время в строке даты
 * @param locale - Языковая локаль (например, 'en-US', 'ru-RU')
 * @returns Отформатированная строка с датой
 */
export function formatDate(
  time: UTCTimestamp,
  format: DateFormat,
  timeFormat: TimeFormat,
  showTime = true,
  locale = 'ru-RU',
): string {
  const d = dayjs.unix(time).locale(locale);

  const findPart = (type: string) => d.format(type);

  let dateString: string;

  switch (format) {
    case DateFormat.DOW_Q_YY: {
      const quarter = Math.floor(d.month() / 3) + 1;
      dateString = `${findPart('ddd')} Q${quarter} '${d.format('YY')}`;
      break;
    }
    case DateFormat.DOW_Q_YYYY: {
      const quarter = Math.floor(d.month() / 3) + 1;
      dateString = `${findPart('ddd')} Q${quarter} ${d.format('YYYY')}`;
      break;
    }
    case DateFormat.DOW_D_MMM_YY:
      dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')} '${d.format('YY')}`;
      break;
    case DateFormat.DOW_MMM_YY:
      dateString = `${findPart('ddd')} ${d.format('MMM')} '${d.format('YY')}`;
      break;
    case DateFormat.DOW_MMM_D_YYYY:
      dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}, ${d.format('YYYY')}`;
      break;
    case DateFormat.DOW_MMM_YYYY:
      dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.format('YYYY')}`;
      break;
    case DateFormat.DOW_MMM_D:
      dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}`;
      break;
    case DateFormat.DOW_D_MMM:
      dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')}`;
      break;
    case DateFormat.DOW_YYYY_MM_DD_DASH:
      dateString = `${findPart('ddd')} ${d.format('YYYY-MM-DD')}`;
      break;
    case DateFormat.DOW_YY_MM_DD_DASH:
      dateString = `${findPart('ddd')} ${d.format('YY-MM-DD')}`;
      break;
    case DateFormat.DOW_YY_MM_DD_SLASH:
      dateString = `${findPart('ddd')} ${d.format('YY/MM/DD')}`;
      break;
    case DateFormat.DOW_YYYY_MM_DD_SLASH:
      dateString = `${findPart('ddd')} ${d.format('YYYY/MM/DD')}`;
      break;
    case DateFormat.DOW_DD_MM_YYYY_DASH:
      dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
      break;
    case DateFormat.DOW_DD_MM_YY_DASH:
      dateString = `${findPart('ddd')} ${d.format('DD-MM-YY')}`;
      break;
    case DateFormat.DOW_DD_MM_YY_SLASH:
      dateString = `${findPart('ddd')} ${d.format('DD/MM/YY')}`;
      break;
    case DateFormat.DOW_DD_MM_YYYY_SLASH:
      dateString = `${findPart('ddd')} ${d.format('DD/MM/YYYY')}`;
      break;
    case DateFormat.DOW_MM_DD_YY_SLASH:
      dateString = `${findPart('ddd')} ${d.format('MM/DD/YY')}`;
      break;
    case DateFormat.DOW_MM_DD_YYYY_SLASH:
      dateString = `${findPart('ddd')} ${d.format('MM/DD/YYYY')}`;
      break;
    case DateFormat.DD_MM_YYYY_HH_mm_ss:
      dateString = d.format('DD.MM.YYYY');
      break;
    default:
      dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
  }

  if (!showTime) return dateString;

  const timeString = customTimeFormatter(time, timeFormat, locale);

  return `${dateString} ${timeString}`;
}

export function createTickMarkFormatter(
  timeFormatString: string,
  locale = 'ru-RU',
): (time: UTCTimestamp, tickMarkType: TickMarkType) => string {
  return (time, tickMarkType) => {
    const d = dayjs.unix(time).locale(locale);

    switch (tickMarkType) {
      case TickMarkType.Year:
        return d.format('YYYY');
      case TickMarkType.Month:
        return d.format('MMM');
      case TickMarkType.DayOfMonth:
        return d.format('DD');
      case TickMarkType.Time:
        return d.format(timeFormatString);
      default:
        return '';
    }
  };
}

export function formatUtcOffset(date = dayjs()): string {
  const offsetMinutes = date.utcOffset();

  const sign = offsetMinutes >= 0 ? '+' : '-';
  const abs = Math.abs(offsetMinutes);
  const hours = Math.floor(abs / 60);
  const minutes = abs % 60;

  if (minutes === 0) return `${sign}${hours}`;

  return `${sign}${hours}:${String(minutes).padStart(2, '0')}`;
}

export function formatDisplayText(value: unknown): string {
  if (value === null || value === undefined) {
    return '';
  }

  if (typeof value === 'string') {
    return value;
  }

  if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
    return String(value);
  }

  if (typeof value === 'object' && 'year' in value && 'month' in value && 'day' in value) {
    const businessDay = value as { year: number; month: number; day: number };

    return `${businessDay.year}.${String(businessDay.month).padStart(2, '0')}.${String(businessDay.day).padStart(2, '0')}`;
  }

  return String(value);
}