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


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

      // TODO: lightweight-charts series.data() currently doesn't return whitespace data
      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 { 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';

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 async ready(): Promise<void> {
    if (this.isInitializedSubject.value) return;

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

  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.concat(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.concat(this.realtimeCache));

    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.concat(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.concat(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 flushRealtimeBuffer(): void {
    if (this.realtimeBuffer.length === 0) return;

    const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
    const bufferedCandles = this.normalizeList(
      this.realtimeBuffer.filter((candle) => candle.time >= newestTime),
    );

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

    this.realtimeBuffer = [];
  }

  private normalizeCandle(candle: Candle): Candle {
    if (candle.time <= 1e10) {
      return candle;
    }

    return {
      ...candle,
      time: Math.floor(candle.time / 1000),
    };
  }

  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 result = data.slice();

  if (dayjsUnit === 'months') {
    let time = lastCandle.time;

    for (let index = 0; index < FUTURE_WHITESPACES; index += 1) {
      const date = new Date(time * 1000);
      const day = date.getUTCDate();

      date.setUTCDate(1);
      date.setUTCMonth(date.getUTCMonth() + candleWidth);

      const lastDay = new Date(
        Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
      ).getUTCDate();

      date.setUTCDate(Math.min(day, lastDay));

      time = Math.floor(date.getTime() / 1000);

      result.push({ time } as Candle);
    }

    return result;
  }

  const secondsPerUnit = {
    seconds: 1,
    minutes: 60,
    hours: 60 * 60,
    days: 24 * 60 * 60,
    weeks: 7 * 24 * 60 * 60,
  }[dayjsUnit];

  if (!secondsPerUnit) {
    return result;
  }

  const step = candleWidth * secondsPerUnit;

  for (let index = 1; index <= FUTURE_WHITESPACES; index += 1) {
    result.push({
      time: lastCandle.time + step * index,
    } as Candle);
  }

  return result;
}



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

import 'dayjs/locale/ru';

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

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

// плагины для работы с UTC и локализацией
dayjs.extend(utc);
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);
}


теперь SymbolSource не формирует свечи, а получает уже готовые данные нужного timeframe. При одинаковом time normalizeSeriesData оставляет последнюю версию свечи, а старый combineCandles мог задвоить volume на пересечении истории.