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


import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';

import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';

import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { normalizeSeriesData, parseTimeframe } from '@src/utils';

dayjs.extend(utc);

const FUTURE_WHITESPACES = 5000;

export interface SymbolSourceParams {
  symbol: string;
  getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
  getTimeframe: () => Timeframes;
}

export class SymbolSource {
  public readonly symbol: string;

  private readonly getData: SymbolSourceParams['getData'];
  private readonly getTimeframe: SymbolSourceParams['getTimeframe'];

  private readonly currentDataSubject = new BehaviorSubject<Candle[]>([]);
  private readonly realtimeSubject = new Subject<Candle>();
  private readonly lastCandleSubject = new BehaviorSubject<Candle | null>(null);
  private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
  private readonly isInitializedSubject = new BehaviorSubject<boolean>(false);

  private realtimeCache: Candle[] = [];
  private realtimeBuffer: Candle[] = [];

  private oldestCandle: Candle | null = null;
  private newestCandle: Candle | null = null;

  private loadSeq = 0;
  private loadingPromise: Promise<void> | null = null;

  private isEndOfData = false;

  constructor({ symbol, getData, getTimeframe }: SymbolSourceParams) {
    this.symbol = symbol;
    this.getData = getData;
    this.getTimeframe = getTimeframe;
  }

  public init(): void {
    this.reload(this.getTimeframe());
  }

  public data$(): Observable<Candle[]> {
    return this.currentDataSubject.asObservable();
  }

  public realtime$(): Observable<Candle> {
    return this.realtimeSubject.asObservable();
  }

  public lastCandle$(): Observable<Candle | null> {
    return this.lastCandleSubject.asObservable();
  }

  public isInitialized$(): Observable<boolean> {
    return this.isInitializedSubject.asObservable();
  }

  public isLoadingValue(): boolean {
    return this.isLoadingSubject.value;
  }

  public getOldestTime(): number | null {
    return this.oldestCandle?.time ?? null;
  }

  public getLastValue(): Candle | null {
    return this.lastCandleSubject.value;
  }

  public destroy(): void {
    this.loadSeq += 1;
    this.loadingPromise = null;

    this.currentDataSubject.complete();
    this.realtimeSubject.complete();
    this.lastCandleSubject.complete();
    this.isLoadingSubject.complete();
    this.isInitializedSubject.complete();

    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
  }

  public pushRealtime(next: Candle): void {
    const candle = this.normalizeCandle(next);

    this.realtimeCache = this.normalizeList([...this.realtimeCache, candle]);

    if (!this.newestCandle || candle.time >= this.newestCandle.time) {
      this.newestCandle = candle;
      this.lastCandleSubject.next(candle);
    }

    if (!this.isInitializedSubject.value) {
      this.realtimeBuffer.push(candle);
      return;
    }

    this.realtimeSubject.next(candle);
  }

  public saveRealtimeCache(): void {
    if (this.realtimeCache.length === 0) return;

    const timeframe = this.getTimeframe();

    const current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
    const next = this.normalizeList([...current, ...this.realtimeCache]);

    this.oldestCandle = next[0] ?? null;
    this.newestCandle = next[next.length - 1] ?? null;
    this.realtimeCache = [];

    this.currentDataSubject.next(applyWhitespacesToFuture(next, timeframe));
    this.lastCandleSubject.next(this.newestCandle);
  }

  public async loadMoreHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      return this.loadingPromise;
    }

    this.loadSeq += 1;

    if (!this.oldestCandle) return;

    const timeframe = this.getTimeframe();

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        if (!this.oldestCandle) return;

        const olderData = await this.getData(timeframe, this.symbol, this.oldestCandle);

        if (olderData === null) {
          this.isEndOfData = true;
          return;
        }

        const older = this.normalizeList(olderData);

        if (older.length === 0) return;

        const current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
        const combined = this.normalizeList([...older, ...current]);

        const nextOldest = combined[0] ?? null;

        if (!nextOldest || !this.oldestCandle || nextOldest.time >= this.oldestCandle.time) {
          return;
        }

        this.oldestCandle = nextOldest;
        this.newestCandle = combined[combined.length - 1] ?? this.newestCandle;

        this.currentDataSubject.next(applyWhitespacesToFuture(combined, timeframe));
        this.lastCandleSubject.next(this.newestCandle);

        this.saveRealtimeCache();
      } catch (error) {
        console.error('[DataSource] Ошибка при догрузке истории:', error);
      } finally {
        this.isLoadingSubject.next(false);
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadAllHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      await this.loadingPromise;
    }

    const seq = ++this.loadSeq;
    const timeframe = this.getTimeframe();

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        let current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
        let oldest = current[0] ?? null;

        if (!oldest) return;

        const seenOldestTimes = new Set<number>();

        while (oldest) {
          if (seq !== this.loadSeq) return;

          if (seenOldestTimes.has(oldest.time)) {
            break;
          }

          seenOldestTimes.add(oldest.time);

          // eslint-disable-next-line no-await-in-loop
          const olderData = await this.getData(timeframe, this.symbol, oldest);

          if (seq !== this.loadSeq) return;

          if (olderData === null) {
            this.isEndOfData = true;
            break;
          }

          const older = this.normalizeList(olderData);

          if (older.length === 0) break;

          const combined = this.normalizeList([...older, ...current]);
          const nextOldest = combined[0] ?? null;

          if (!nextOldest || nextOldest.time >= oldest.time) {
            break;
          }

          oldest = nextOldest;
          current = combined;
        }

        this.oldestCandle = current[0] ?? null;
        this.newestCandle = current[current.length - 1] ?? null;

        this.currentDataSubject.next(applyWhitespacesToFuture(current, timeframe));
        this.lastCandleSubject.next(this.newestCandle);

        this.saveRealtimeCache();
      } catch (error) {
        console.error('[DataSource] Ошибка при полной загрузке истории:', error);
      } finally {
        if (seq === this.loadSeq) {
          this.isLoadingSubject.next(false);
        }
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadTill(time: number): Promise<void> {
    await this.ready();

    while (this.oldestCandle && this.oldestCandle.time >= time) {
      const before = this.oldestCandle.time;

      // eslint-disable-next-line no-await-in-loop
      await this.loadMoreHistory();

      if (!this.oldestCandle) break;
      if (this.oldestCandle.time === before) break;
    }
  }

  public async reload(timeframe: Timeframes): Promise<void> {
    this.loadSeq += 1;

    const seq = this.loadSeq;

    this.isInitializedSubject.next(false);
    this.isLoadingSubject.next(true);

    this.currentDataSubject.next([]);
    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
    this.isEndOfData = false;
    this.lastCandleSubject.next(null);

    const task = (async () => {
      try {
        const loaded = (await this.getData(timeframe, this.symbol)) ?? [];

        if (seq !== this.loadSeq) return;

        const normalized = this.normalizeList(loaded);

        this.oldestCandle = normalized[0] ?? null;
        this.newestCandle = normalized[normalized.length - 1] ?? null;

        this.currentDataSubject.next(applyWhitespacesToFuture(normalized, timeframe));
        this.lastCandleSubject.next(this.newestCandle);

        this.isInitializedSubject.next(true);
        this.flushRealtimeBuffer();
      } catch (error) {
        console.error('[DataSource] Ошибка при загрузке данных:', error);
      } finally {
        if (seq === this.loadSeq) {
          this.isLoadingSubject.next(false);
        }
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  private async ready(): Promise<void> {
    if (this.isInitializedSubject.value) return;

    await firstValueFrom(this.isInitializedSubject.pipe(filter(Boolean), take(1)));
  }

  private flushRealtimeBuffer(): void {
    if (this.realtimeBuffer.length === 0) return;

    const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;

    const unique = this.normalizeList(
      this.realtimeBuffer.filter((candle) => candle.time >= newestTime),
    );

    for (const candle of unique) {
      this.realtimeSubject.next(candle);
    }

    this.realtimeBuffer = [];
  }

  private normalizeCandle(candle: Candle): Candle {
    const time = candle.time > 1e10 ? Math.floor(candle.time / 1000) : Math.floor(candle.time);

    if (time === candle.time) {
      return candle;
    }

    return {
      ...candle,
      time,
    };
  }

  private normalizeList(candles: Candle[]): Candle[] {
    return normalizeSeriesData(candles.map((candle) => this.normalizeCandle(candle)));
  }
}

export function applyWhitespacesToFuture(data: Candle[], timeframe: Timeframes): Candle[] {
  const lastCandle = data[data.length - 1];

  if (!lastCandle) {
    return data;
  }

  const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
  const startTime = dayjs.unix(lastCandle.time).utc();
  const result = [...data];

  for (let index = 1; index <= FUTURE_WHITESPACES; index += 1) {
    result.push({
      time: startTime.add(candleWidth * index, dayjsUnit).unix(),
    } as Candle);
  }

  return result;
}


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

      let currentBar: BarData | LineData | HistogramData | CustomData | null = null;
      let previousBar: BarData | LineData | HistogramData | CustomData | null = null;

      for (let index = seriesData.length - 1; index >= 0; index -= 1) {
        const bar = seriesData[index];

        if (!bar || (!isBarData(bar) && !isLineData(bar))) {
          continue;
        }

        if (!currentBar) {
          currentBar = bar;
          continue;
        }

        previousBar = bar;
        break;
      }

      return this.formatLegendValues(currentBar, previousBar);
    }

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

// плагины для локализации и работы с duration
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'));
}

/**
 * Форматирует 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);
}

import type { Candle as MoexChartCandle } from 'moex-chart';
import type { Candle as ApiCandle } from 'types/Candles';

const getCandleStartTime = (time: string): number => {
  const normalizedTime = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(time) ? time : `${time}Z`;

  return Math.floor(Date.parse(normalizedTime) / 1000);
};

export const candleToBar = ({
  open,
  close,
  high,
  low,
  volume,
  begin,
}: ApiCandle): MoexChartCandle => ({
  open,
  close,
  high,
  low,
  // TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
  // В котировках volume и value значения всегда null
  volume: volume || 0,
  time: getCandleStartTime(begin),
});


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

import { parseTimeframe } from 'moex-chart';

import {
  moexChartTimeConverter,
  moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';

import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';

import type { Candle, Timeframes } from 'moex-chart';

dayjs.extend(duration);

interface HistoryRequestState {
  untilTime?: number;
  request: Promise<Candle[] | null> | null;
}

function getRequestSymbol(symbolRaw?: string): string | undefined {
  const symbol = String(symbolRaw ?? '').trim();

  if (!symbol || symbol === DEFAULT_SYMBOL) {
    return undefined;
  }

  return symbol;
}

function getTimeframeSeconds(timeframe: Timeframes): number {
  const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

  return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}

function aggregateCandles(candles: Candle[], time: number): Candle | undefined {
  const firstCandle = candles[0];
  const lastCandle = candles[candles.length - 1];

  if (!firstCandle || !lastCandle) {
    return undefined;
  }

  return {
    time,
    open: firstCandle.open,
    high: Math.max(...candles.map(({ high }) => high)),
    low: Math.min(...candles.map(({ low }) => low)),
    close: lastCandle.close,
    volume: candles.reduce((total, candle) => total + (candle.volume ?? 0), 0),
  };
}

// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
  private prevRealtimeDataArr: Candle[] = [];

  private prevRealtimeData: Candle | undefined;

  private realtimeShouldBeConvoluted = false;

  private realtimeSessionStart: number | null = null;

  private realtimeTimer: ReturnType<typeof setInterval> | null = null;

  private historyRequests = new Map<string, HistoryRequestState>();

  public getDataSource =
    (indicativeData?: ChartIndicativeData, cb?: (timeframe: Timeframes) => void) =>
    async (
      timeframe: Timeframes,
      symbolId: string,
      until?: Candle,
    ): Promise<Candle[] | null> => {
      const symbol = getRequestSymbol(symbolId);

      if (!symbol) {
        return null;
      }

      const historyRequestKey = `${symbol}:${timeframe}`;
      const historyRequestState = this.historyRequests.get(historyRequestKey);

      if (historyRequestState && historyRequestState.untilTime === until?.time) {
        if (historyRequestState.request) {
          return historyRequestState.request;
        }

        if (until) {
          return null;
        }
      }

      cb?.(timeframe);

      const historyRequest = this.requestHistoryData({
        timeframe,
        symbol,
        until,
        indicativeData,
      });

      this.historyRequests.set(historyRequestKey, {
        untilTime: until?.time,
        request: historyRequest,
      });

      try {
        return await historyRequest;
      } finally {
        if (this.historyRequests.get(historyRequestKey)?.request === historyRequest) {
          if (until) {
            this.historyRequests.set(historyRequestKey, {
              untilTime: until.time,
              request: null,
            });
          } else {
            this.historyRequests.delete(historyRequestKey);
          }
        }
      }
    };

  public startRealtime({
    getSymbols,
    getTimeframe,
    update,
    periodMs = 5000,
    indicativeData,
  }: {
    getSymbols: () => string[];
    getTimeframe: () => Timeframes;
    update: (symbolId: string, candle: Candle) => void;
    periodMs?: number;
    indicativeData?: ChartIndicativeData;
  }): () => void {
    if (this.realtimeTimer) {
      clearInterval(this.realtimeTimer);
    }

    this.realtimeTimer = setInterval(() => {
      const timeframe = getTimeframe();
      const symbolIds = getSymbols();

      Promise.all(
        symbolIds.map(async (symbolId) => {
          const symbol = getRequestSymbol(symbolId);

          if (!symbol) {
            return;
          }

          const data = await requestRealtimeBars({
            currencyPair: symbol.replaceAll(':', '.'),
            interval: moexChartTimeConverter(timeframe),
            ticker: symbol,
            indicativeData,
          });

          if (!data) {
            return;
          }

          if (!this.realtimeShouldBeConvoluted) {
            this.prevRealtimeData = data;
            update(symbol, data);
            return;
          }

          if (
            this.prevRealtimeData &&
            JSON.stringify(data) === JSON.stringify(this.prevRealtimeData)
          ) {
            return;
          }

          this.realtimeConvolution(timeframe, data, (candle) => {
            update(symbol, candle);
          });
        }),
      );
    }, periodMs);

    return () => {
      if (this.realtimeTimer) {
        clearInterval(this.realtimeTimer);
      }

      this.realtimeTimer = null;
    };
  }

  private async requestHistoryData({
    timeframe,
    symbol,
    until,
    indicativeData,
  }: {
    timeframe: Timeframes;
    symbol: string;
    until?: Candle;
    indicativeData?: ChartIndicativeData;
  }): Promise<Candle[] | null> {
    const interval = moexChartTimeConverter(timeframe);
    const date = until?.time ?? Math.round(Date.now() / 1000);

    const data = await requestBars({
      currencyPair: symbol.replaceAll(':', '.'),
      interval,
      periodParams: {
        firstDataRequest: true,
        to: date,
        from: Math.round(Date.now() / 1000),
        countBack: 2000,
      },
      ticker: symbol,
      indicativeData,
    });

    if (data.length === 0) {
      return null;
    }

    const issTimeframe = moexChartToIssTimeframe(timeframe);

    if (issTimeframe === timeframe) {
      this.realtimeShouldBeConvoluted = false;

      if (!until) {
        this.prevRealtimeData = data[data.length - 1];
        this.prevRealtimeDataArr = [];
        this.realtimeSessionStart = null;
      }

      return data;
    }

    this.realtimeShouldBeConvoluted = true;

    return this.timeframeConvolution(data, timeframe, !until);
  }

  private timeframeConvolution(
    data: Candle[],
    requestedTimeframe: Timeframes,
    syncRealtime: boolean,
  ): Candle[] {
    const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
    const sortedData = [...data].sort((first, second) => first.time - second.time);

    const firstCandle = sortedData[0];

    if (!firstCandle) {
      return [];
    }

    const result: Candle[] = [];

    let sessionStart = firstCandle.time;
    let bucketStart = sessionStart;
    let candleGroup: Candle[] = [];

    sortedData.forEach((candle, index) => {
      const previousCandle = sortedData[index - 1];

      const isNewSession =
        previousCandle &&
        candle.time - previousCandle.time > timeframeSeconds;

      if (isNewSession) {
        const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);

        if (aggregatedCandle) {
          result.push(aggregatedCandle);
        }

        sessionStart = candle.time;
        bucketStart = candle.time;
        candleGroup = [candle];

        return;
      }

      const currentBucketStart =
        sessionStart +
        Math.floor((candle.time - sessionStart) / timeframeSeconds) * timeframeSeconds;

      if (currentBucketStart !== bucketStart) {
        const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);

        if (aggregatedCandle) {
          result.push(aggregatedCandle);
        }

        bucketStart = currentBucketStart;
        candleGroup = [];
      }

      candleGroup.push(candle);
    });

    const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);

    if (aggregatedCandle) {
      result.push(aggregatedCandle);
    }

    if (syncRealtime) {
      this.realtimeSessionStart = sessionStart;
      this.prevRealtimeDataArr = [...candleGroup];
      this.prevRealtimeData = sortedData[sortedData.length - 1];
    }

    return result;
  }

  private realtimeConvolution(
    timeframe: Timeframes,
    data: Candle,
    update: (candle: Candle) => void,
  ): void {
    const timeframeSeconds = getTimeframeSeconds(timeframe);

    if (!this.prevRealtimeData || this.realtimeSessionStart === null) {
      this.realtimeSessionStart = data.time;
      this.prevRealtimeDataArr = [data];
    } else {
      const isNewSession =
        data.time - this.prevRealtimeData.time > timeframeSeconds;

      if (isNewSession) {
        this.realtimeSessionStart = data.time;
        this.prevRealtimeDataArr = [data];
      } else {
        const previousBucketStart =
          this.realtimeSessionStart +
          Math.floor(
            (this.prevRealtimeData.time - this.realtimeSessionStart) / timeframeSeconds,
          ) *
            timeframeSeconds;

        const currentBucketStart =
          this.realtimeSessionStart +
          Math.floor(
            (data.time - this.realtimeSessionStart) / timeframeSeconds,
          ) *
            timeframeSeconds;

        if (currentBucketStart !== previousBucketStart) {
          this.prevRealtimeDataArr = [data];
        } else {
          const candleIndex = this.prevRealtimeDataArr.findIndex(
            (candle) => candle.time === data.time,
          );

          if (candleIndex === -1) {
            this.prevRealtimeDataArr.push(data);
          } else {
            this.prevRealtimeDataArr[candleIndex] = data;
          }
        }
      }
    }

    this.prevRealtimeData = data;

    const sessionStart = this.realtimeSessionStart;

    if (sessionStart === null) {
      return;
    }

    const bucketStart =
      sessionStart +
      Math.floor((data.time - sessionStart) / timeframeSeconds) * timeframeSeconds;

    const candle = aggregateCandles(this.prevRealtimeDataArr, bucketStart);

    if (candle) {
      update(candle);
    }
  }
}

export { DataSourceProvider };


if (interval === Intervals.All) {
  Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)))
    .then(() => {
      const firstTimes = symbolIds
        .map((symbolId) => this.dataSource.getOldestTime(symbolId))
        .filter((time): time is number => time !== null);

      const lastTimes = symbolIds
        .map((symbolId) => this.dataSource.getLastCandle(symbolId)?.time)
        .filter((time): time is number => time !== undefined);

      if (firstTimes.length === 0 || lastTimes.length === 0) {
        return;
      }

      requestAnimationFrame(() => {
        this.lwcChart.timeScale().setVisibleRange({
          from: Math.min(...firstTimes) as Time,
          to: Math.max(...lastTimes) as Time,
        });
      });
    })
    .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

  return;
}