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


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

function debugLog(label: string, value: unknown): void {
  console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}

function debugTime(time: unknown) {
  if (typeof time !== 'number' || !Number.isFinite(time)) {
    return {
      time,
      type: typeof time,
    };
  }

  const secondsDate = new Date(time * 1000);
  const millisecondsDate = new Date(time);

  return {
    time,
    likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
    asSeconds: Number.isNaN(secondsDate.getTime()) ? 'Invalid Date' : secondsDate.toISOString(),
    asMilliseconds: Number.isNaN(millisecondsDate.getTime())
      ? 'Invalid Date'
      : millisecondsDate.toISOString(),
  };
}

function debugSeriesData(label: string, data: readonly { time: Time }[]): void {
  debugLog(label, {
    count: data.length,
    first: data.slice(0, 8).map(({ time }) => debugTime(time)),
    last: data.slice(-8).map(({ time }) => debugTime(time)),
  });
}

function applyLocalTimezone(candles: Candle[]): Candle[] {
  // todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
  return candles.flatMap((candle) => {
    if (typeof candle.time !== 'number' || !Number.isFinite(candle.time)) {
      return [];
    }

    const offsetSeconds = new Date(candle.time * 1000).getTimezoneOffset() * 60;

    return [
      {
        ...candle,
        time: candle.time - offsetSeconds,
      },
    ];
  });
}

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;

    debugLog('[TIME DEBUG][BaseSeries][created]', {
      seriesType: this.lwcSeries.seriesType(),
    });
  }

  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 {
    const normalizedData = normalizeSeriesData(data);

    debugSeriesData(
      `[TIME DEBUG][BaseSeries][${this.seriesType()}][LWC setData]`,
      normalizedData,
    );

    this.lwcSeries.setData(normalizedData);
  }

  public setSeriesOrder(order: number): void {
    this.lwcSeries.setSeriesOrder(order);
  }

  public subscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.subscribeDataChanged(handler);
  }

  public unsubscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.unsubscribeDataChanged(handler);
  }

  public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
    debugSeriesData(
      `[TIME DEBUG][BaseSeries][${this.seriesType()}][LWC update]`,
      [bar],
    );

    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 applyTimezone(data: Candle[]): Candle[] {
    return applyLocalTimezone(data);
  }

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    debugSeriesData(
      `[TIME DEBUG][BaseSeries][${this.seriesType()}][formatData input]`,
      inputData,
    );

    const data = this.applyTimezone(inputData);

    debugSeriesData(
      `[TIME DEBUG][BaseSeries][${this.seriesType()}][after timezone]`,
      data,
    );

    if (!this.customFormatter) {
      const formattedData = this.formatMainSerie(data);

      debugSeriesData(
        `[TIME DEBUG][BaseSeries][${this.seriesType()}][after formatMainSerie]`,
        formattedData,
      );

      return formattedData;
    }

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

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

      debugSeriesData(
        `[TIME DEBUG][BaseSeries][${this.seriesType()}][after customFormatter]`,
        formattedData,
      );

      return formattedData;
    }

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

    if (!candle) {
      return [];
    }

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

    debugSeriesData(
      `[TIME DEBUG][BaseSeries][${this.seriesType()}][after customFormatter realtime]`,
      formattedData,
    );

    return formattedData;
  }

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

const TIME_SCALE_DEBUG_LIMIT = 100;
const FORMAT_DATE_DEBUG_LIMIT = 50;

let timeScaleDebugCount = 0;
let formatDateDebugCount = 0;

function debugLog(label: string, value: unknown): void {
  console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}

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).utc().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).utc().locale(locale);

  if (formatDateDebugCount < FORMAT_DATE_DEBUG_LIMIT) {
    debugLog('[TIME DEBUG][formatDate]', {
      time,
      iso: new Date(time * 1000).toISOString(),
      utcFormatted: d.format('DD.MM.YYYY HH:mm:ss'),
      browserLocal: new Date(time * 1000).toString(),
      format,
      timeFormat,
      showTime,
    });

    formatDateDebugCount += 1;
  }

  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).utc().locale(locale);

    let formatted: string;

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

    if (timeScaleDebugCount < TIME_SCALE_DEBUG_LIMIT) {
      debugLog('[TIME DEBUG][TimeScale][tickMarkFormatter]', {
        time,
        iso: new Date(time * 1000).toISOString(),
        browserLocal: new Date(time * 1000).toString(),
        tickMarkType,
        timeFormatString,
        formatted,
      });

      timeScaleDebugCount += 1;
    }

    return formatted;
  };
}

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 CANDLE_DEBUG_LIMIT = 100;

let candleDebugCount = 0;

function debugLog(label: string, value: unknown): void {
  console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}

const parseUtcTimestamp = (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,
  end,
}: ApiCandle): MoexChartCandle => {
  const time = parseUtcTimestamp(begin);

  if (candleDebugCount < CANDLE_DEBUG_LIMIT) {
    debugLog('[TIME DEBUG][candleToBar]', {
      source: {
        begin,
        end,
      },
      result: {
        time,
        likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
        normalizedUtc: new Date(time * 1000).toISOString(),
        browserLocal: new Date(time * 1000).toString(),
        timezoneOffsetMinutes: new Date(time * 1000).getTimezoneOffset(),
      },
    });

    candleDebugCount += 1;
  }

  return {
    open,
    close,
    high,
    low,
    // TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
    // В котировках volume и value значения всегда null
    volume: volume ?? 0,
    time,
  };
};


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 debugLog(label: string, value: unknown): void {
  console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}

function debugCandleTime(time: number) {
  const secondsDate = new Date(time * 1000);
  const millisecondsDate = new Date(time);

  return {
    time,
    likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
    asSeconds: Number.isNaN(secondsDate.getTime())
      ? 'Invalid Date'
      : secondsDate.toISOString(),
    localFromSeconds: Number.isNaN(secondsDate.getTime())
      ? 'Invalid Date'
      : secondsDate.toString(),
    asMilliseconds: Number.isNaN(millisecondsDate.getTime())
      ? 'Invalid Date'
      : millisecondsDate.toISOString(),
  };
}

function debugCandles(label: string, candles: Candle[]): void {
  debugLog(label, {
    count: candles.length,
    first: candles.slice(0, 8).map((candle) => ({
      ...debugCandleTime(candle.time),
      open: candle.open,
      high: candle.high,
      low: candle.low,
      close: candle.close,
      volume: candle.volume,
    })),
    last: candles.slice(-8).map((candle) => ({
      ...debugCandleTime(candle.time),
      open: candle.open,
      high: candle.high,
      low: candle.low,
      close: candle.close,
      volume: candle.volume,
    })),
  });
}

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

      debugLog('[TIME DEBUG][DataSourceProvider][getDataSource]', {
        timeframe,
        symbolId,
        symbol,
        until: until
          ? debugCandleTime(until.time)
          : null,
      });

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

      if (
        historyRequestState &&
        historyRequestState.untilTime === until?.time
      ) {
        if (historyRequestState.request) {
          debugLog(
            '[TIME DEBUG][DataSourceProvider][reuse history request]',
            {
              historyRequestKey,
              untilTime: historyRequestState.untilTime,
            },
          );

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

          debugCandles(
            `[TIME DEBUG][DataSourceProvider][realtime raw][${timeframe}]`,
            [data],
          );

          if (!this.realtimeShouldBeConvoluted) {
            this.prevRealtimeData = data;

            debugCandles(
              `[TIME DEBUG][DataSourceProvider][realtime direct update][${timeframe}]`,
              [data],
            );

            update(symbol, data);

            return;
          }

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

          this.realtimeConvolution(timeframe, data, (candle) => {
            debugCandles(
              `[TIME DEBUG][DataSourceProvider][realtime after convolution][${timeframe}]`,
              [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);

    debugLog('[TIME DEBUG][DataSourceProvider][request]', {
      timeframe,
      interval,
      symbol,
      until: until
        ? debugCandleTime(until.time)
        : null,
      requestTo: debugCandleTime(date),
    });

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

    debugCandles(
      `[TIME DEBUG][DataSourceProvider][requestBars result][${timeframe}]`,
      data,
    );

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

    const issTimeframe = moexChartToIssTimeframe(timeframe);

    debugLog('[TIME DEBUG][DataSourceProvider][timeframes]', {
      requestedTimeframe: timeframe,
      issTimeframe,
      shouldConvolute: issTimeframe !== timeframe,
    });

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

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

      debugCandles(
        `[TIME DEBUG][DataSourceProvider][history direct][${timeframe}]`,
        data,
      );

      return data;
    }

    this.realtimeShouldBeConvoluted = true;

    const convolutedData = this.timeframeConvolution(
      data,
      timeframe,
      !until,
    );

    debugCandles(
      `[TIME DEBUG][DataSourceProvider][after convolution][${timeframe}]`,
      convolutedData,
    );

    return convolutedData;
  }

  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 [];
    }

    debugLog(
      `[TIME DEBUG][DataSourceProvider][convolution config][${requestedTimeframe}]`,
      {
        requestedTimeframe,
        timeframeSeconds,
        firstCandle: debugCandleTime(firstCandle.time),
        syncRealtime,
      },
    );

    debugCandles(
      `[TIME DEBUG][DataSourceProvider][convolution source][${requestedTimeframe}]`,
      sortedData,
    );

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

        debugLog(
          `[TIME DEBUG][DataSourceProvider][new session][${requestedTimeframe}]`,
          {
            previous: previousCandle
              ? debugCandleTime(previousCandle.time)
              : null,
            current: debugCandleTime(candle.time),
            differenceSeconds: previousCandle
              ? candle.time - previousCandle.time
              : null,
            timeframeSeconds,
          },
        );

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

    debugCandles(
      `[TIME DEBUG][DataSourceProvider][convolution result][${requestedTimeframe}]`,
      result,
    );

    return result;
  }

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

    debugLog(
      `[TIME DEBUG][DataSourceProvider][realtime convolution config][${timeframe}]`,
      {
        timeframe,
        timeframeSeconds,
        incoming: debugCandleTime(data.time),
        previous: this.prevRealtimeData
          ? debugCandleTime(this.prevRealtimeData.time)
          : null,
        sessionStart:
          this.realtimeSessionStart === null
            ? null
            : debugCandleTime(this.realtimeSessionStart),
      },
    );

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