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


import dayjs from 'dayjs';

import {
  Coordinate,
  IChartApi,
  ISeriesApi,
  LineSeries,
  LogicalRange,
  SeriesPartialOptionsMap,
  Time,
  WhitespaceData,
} from 'lightweight-charts';

import { Observable, Subscription } from 'rxjs';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';

import type { Intervals } from '@src/types/intervals';
import type { Timeframes } from '@src/types/timeframes';

type TimeStepUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';

interface TimeStep {
  unit: TimeStepUnit;
  value: number;
  key: string;
}

interface TimeScaleFuturePaddingParams {
  chart: IChartApi;
  mainSeries$: Observable<SeriesStrategies | null>;
  timeframe$?: Observable<Timeframes | null>;
  interval$?: Observable<Intervals | null>;
  maxFutureBarsByUnit?: Partial<Record<TimeStepUnit, number>>;
}

const DAY_SECONDS = 86_400;
const MIN_FUTURE_BARS = 12;
const RESERVE_VISIBLE_PART = 0.35;

const DEFAULT_MAX_FUTURE_BARS_BY_UNIT: Record<TimeStepUnit, number> = {
  second: 600,
  minute: 720,
  hour: 360,
  day: 366,
  week: 260,
  month: 120,
  year: 30,
};

export class TimeScaleFuturePadding {
  private readonly chart: IChartApi;
  private readonly paddingSeries: ISeriesApi<'Line'>;
  private readonly subscriptions = new Subscription();
  private readonly maxFutureBarsByUnit: Record<TimeStepUnit, number>;

  private mainSeries: SeriesStrategies | null = null;
  private currentTimeframe: Timeframes | null = null;
  private paddingKey = '';
  private isDestroyed = false;

  constructor({
    chart,
    mainSeries$,
    timeframe$,
    interval$,
    maxFutureBarsByUnit,
  }: TimeScaleFuturePaddingParams) {
    this.chart = chart;
    this.maxFutureBarsByUnit = {
      ...DEFAULT_MAX_FUTURE_BARS_BY_UNIT,
      ...maxFutureBarsByUnit,
    };

    this.paddingSeries = chart.addSeries(LineSeries, this.getPaddingSeriesOptions());

    this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        this.setMainSeries(series);
      }),
    );

    if (timeframe$) {
      this.subscriptions.add(
        timeframe$.subscribe((timeframe) => {
          this.currentTimeframe = timeframe;
          this.updatePadding();
        }),
      );
    }

    if (interval$) {
      this.subscriptions.add(
        interval$.subscribe(() => {
          this.updatePadding();
        }),
      );
    }
  }

  public destroy(): void {
    if (this.isDestroyed) {
      return;
    }

    this.isDestroyed = true;

    this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
    this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
    this.subscriptions.unsubscribe();

    try {
      this.chart.removeSeries(this.paddingSeries);
    } catch {
      this.paddingKey = '';
    }
  }

  private getPaddingSeriesOptions(): SeriesPartialOptionsMap['Line'] {
    return {
      priceScaleId: '',
      lineVisible: false,
      pointMarkersVisible: false,
      lastValueVisible: false,
      priceLineVisible: false,
      crosshairMarkerVisible: false,
      title: '',
    };
  }

  private setMainSeries(series: SeriesStrategies | null): void {
    if (this.mainSeries === series) {
      return;
    }

    this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);

    this.mainSeries = series;

    this.mainSeries?.subscribeDataChanged(this.handleDataChanged);

    this.updatePadding();
  }

  private handleDataChanged = (): void => {
    this.updatePadding();
  };

  private handleVisibleLogicalRangeChange = (): void => {
    this.updatePadding();
  };

  private updatePadding(): void {
    if (this.isDestroyed) {
      return;
    }

    if (!this.mainSeries) {
      this.clearPadding();
      return;
    }

    const sourceData = this.mainSeries.data();

    if (!sourceData.length) {
      this.clearPadding();
      return;
    }

    const lastItem = sourceData[sourceData.length - 1];

    if (!lastItem || typeof lastItem.time !== 'number' || !Number.isFinite(lastItem.time)) {
      this.clearPadding();
      return;
    }

    const timeStep = this.getTimeStep(sourceData);
    const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
    const lastRealLogical = this.getLastRealLogicalIndex(lastItem.time, sourceData.length - 1);
    const futureBarsCount = this.getFutureBarsCount(visibleRange, lastRealLogical, timeStep);

    if (futureBarsCount <= 0) {
      this.clearPadding();
      return;
    }

    const nextPaddingKey = `${sourceData.length}:${lastItem.time}:${timeStep.key}:${futureBarsCount}`;

    if (this.paddingKey === nextPaddingKey) {
      return;
    }

    this.paddingKey = nextPaddingKey;
    this.paddingSeries.setData(this.createPaddingData(lastItem.time, timeStep, futureBarsCount));
  }

  private clearPadding(): void {
    if (!this.paddingKey) {
      return;
    }

    this.paddingKey = '';
    this.paddingSeries.setData([]);
  }

  private getLastRealLogicalIndex(time: Time, fallback: number): number {
    const coordinate = this.chart.timeScale().timeToCoordinate(time);

    if (coordinate === null) {
      return fallback;
    }

    const logical = this.chart.timeScale().coordinateToLogical(coordinate as Coordinate);

    if (logical === null || !Number.isFinite(logical)) {
      return fallback;
    }

    return Number(logical);
  }

  private getFutureBarsCount(
    visibleRange: LogicalRange | null,
    lastRealLogical: number,
    timeStep: TimeStep,
  ): number {
    const maxFutureBars = this.maxFutureBarsByUnit[timeStep.unit];

    if (!visibleRange) {
      return Math.min(maxFutureBars, MIN_FUTURE_BARS);
    }

    const visibleBars = Math.max(1, Math.ceil(visibleRange.to - visibleRange.from));
    const visibleFutureBars = Math.max(0, Math.ceil(visibleRange.to - lastRealLogical));
    const reserveBars = Math.max(MIN_FUTURE_BARS, Math.ceil(visibleBars * RESERVE_VISIBLE_PART));

    return Math.min(maxFutureBars, visibleFutureBars + reserveBars);
  }

  private createPaddingData(lastTime: number, timeStep: TimeStep, count: number): WhitespaceData<Time>[] {
    const data: WhitespaceData<Time>[] = [];

    for (let index = 1; index <= count; index += 1) {
      data.push({
        time: this.getNextTime(lastTime, timeStep, index),
      });
    }

    return data;
  }

  private getNextTime(lastTime: number, timeStep: TimeStep, index: number): Time {
    const stepValue = timeStep.value * index;

    if (timeStep.unit === 'month' || timeStep.unit === 'year') {
      return dayjs.unix(lastTime).add(stepValue, timeStep.unit).unix() as Time;
    }

    return (lastTime + this.getStepSeconds(timeStep) * index) as Time;
  }

  private getStepSeconds(timeStep: TimeStep): number {
    switch (timeStep.unit) {
      case 'second':
        return timeStep.value;
      case 'minute':
        return timeStep.value * 60;
      case 'hour':
        return timeStep.value * 3_600;
      case 'day':
        return timeStep.value * DAY_SECONDS;
      case 'week':
        return timeStep.value * DAY_SECONDS * 7;
      default:
        return DAY_SECONDS;
    }
  }

  private getTimeStep(data: readonly { time: Time }[]): TimeStep {
    if (this.currentTimeframe) {
      const timeStep = parseTimeframe(String(this.currentTimeframe));

      if (timeStep) {
        return timeStep;
      }
    }

    return inferTimeStepFromData(data);
  }
}

function parseTimeframe(timeframe: string): TimeStep | null {
  const match = timeframe.match(/^(\d+)(s|m|h|d|w|M|y)$/);

  if (!match) {
    return null;
  }

  const value = Number(match[1]);
  const unit = match[2];

  if (!Number.isFinite(value) || value <= 0) {
    return null;
  }

  if (unit === 's') {
    return createTimeStep('second', value, timeframe);
  }

  if (unit === 'm') {
    return createTimeStep('minute', value, timeframe);
  }

  if (unit === 'h') {
    return createTimeStep('hour', value, timeframe);
  }

  if (unit === 'd') {
    return createTimeStep('day', value, timeframe);
  }

  if (unit === 'w') {
    return createTimeStep('week', value, timeframe);
  }

  if (unit === 'M') {
    return createTimeStep('month', value, timeframe);
  }

  if (unit === 'y') {
    return createTimeStep('year', value, timeframe);
  }

  return null;
}

function inferTimeStepFromData(data: readonly { time: Time }[]): TimeStep {
  const times = data.reduce<number[]>((acc, item) => {
    if (typeof item.time === 'number' && Number.isFinite(item.time)) {
      acc.push(Number(item.time));
    }

    return acc;
  }, []);

  if (times.length < 2) {
    return createTimeStep('minute', 1, 'inferred:minute:1');
  }

  const uniqueTimes = Array.from(new Set(times)).sort((a, b) => a - b);
  const latestTimes = uniqueTimes.slice(-100);
  const distances: number[] = [];

  for (let index = 1; index < latestTimes.length; index += 1) {
    const prevTime = latestTimes[index - 1];
    const currentTime = latestTimes[index];
    const distance = currentTime - prevTime;

    if (distance > 0) {
      distances.push(distance);
    }
  }

  if (!distances.length) {
    return createTimeStep('minute', 1, 'inferred:minute:1');
  }

  distances.sort((a, b) => a - b);

  const medianDistance = distances[Math.floor(distances.length / 2)];

  if (medianDistance < 60) {
    const value = Math.max(1, Math.round(medianDistance));

    return createTimeStep('second', value, `inferred:second:${value}`);
  }

  if (medianDistance < 3_600) {
    const value = Math.max(1, Math.round(medianDistance / 60));

    return createTimeStep('minute', value, `inferred:minute:${value}`);
  }

  if (medianDistance < DAY_SECONDS) {
    const value = Math.max(1, Math.round(medianDistance / 3_600));

    return createTimeStep('hour', value, `inferred:hour:${value}`);
  }

  if (medianDistance < DAY_SECONDS * 27) {
    const value = Math.max(1, Math.round(medianDistance / DAY_SECONDS));

    return createTimeStep('day', value, `inferred:day:${value}`);
  }

  if (medianDistance < DAY_SECONDS * 90) {
    const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 30)));

    return createTimeStep('month', value, `inferred:month:${value}`);
  }

  if (medianDistance < DAY_SECONDS * 365) {
    const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 7)));

    return createTimeStep('week', value, `inferred:week:${value}`);
  }

  const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 365)));

  return createTimeStep('year', value, `inferred:year:${value}`);
}

function createTimeStep(unit: TimeStepUnit, value: number, key: string): TimeStep {
  return {
    unit,
    value,
    key,
  };
}