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


import dayjs from 'dayjs';

import { BehaviorSubject, Observable, Subject } from 'rxjs';

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

    const { loadingPromise } = this;

    if (!loadingPromise) {
      return Promise.resolve();
    }

    return loadingPromise.then(() => {
      if (this.isInitializedSubject.value) {
        return;
      }

      return this.waitUntilReady();
    });
  }

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

    if (this.isEndOfData) return;

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

    if (!this.oldestCandle) return;

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

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

      try {
        const olderData = await this.getData(timeframe, this.symbol, oldestCandle);
        if (seq !== this.loadSeq) return;

        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 {
        if (seq === this.loadSeq) 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.waitUntilReady();

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

    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 {
    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.slice();

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

  return result;
}

















import { ChartIcon, SeasonalityIcon } from '@src/components/Icon';

import { ChartViewType } from '@src/types';

interface ChartViewTypeIconProps {
  type?: ChartViewType;
}

export function ChartViewTypeIcon({ type }: ChartViewTypeIconProps) {
  switch (type) {
    case ChartViewType.Seasonality:
      return <SeasonalityIcon />;
    default:
      return <ChartIcon />;
  }
}

















import { ChartIcon, SeasonalityIcon } from '@src/components/Icon';

import { ChartViewType } from '@src/types';

interface ChartViewTypeIconProps {
  type?: ChartViewType;
}

export function ChartViewTypeIcon({ type }: ChartViewTypeIconProps) {
  switch (type) {
    case ChartViewType.Seasonality:
      return <SeasonalityIcon />;
    default:
      return <ChartIcon />;
  }
}

















import { SeriesType, Time } from 'lightweight-charts';

import { DateFormat } from '@src/utils/formatter';

import { TimeFormat } from './timeScale';
/**
 * Типы графиков, поддерживаемые библиотекой
 */
export type ChartSeriesType = SeriesType;

/**
 * Универсальный формат данных для всех типов графиков
 */
export interface ChartDataPoint {
  time?: Time;
  value?: number;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  volume?: number;
}

interface CandlestickChartOptions {
  upColor?: string;
  downColor?: string;
  wickUpColor?: string;
  wickDownColor?: string;
  borderVisible?: boolean;
}

interface LineChartOptions {
  lineStyle?: 'solid' | 'dotted' | 'dashed';
}

interface AreaChartOptions {
  topColor?: string;
  bottomColor?: string;
}

interface BaselineChartOptions {
  baseValue?: number;
  topLineColor?: string;
  bottomLineColor?: string;
  topFillColor1?: string;
  topFillColor2?: string;
  bottomFillColor1?: string;
  bottomFillColor2?: string;
}

interface TimescaleOptions {
  timeVisible?: boolean;
  secondsVisible?: boolean;
  timeFormat?: TimeFormat;
  dateFormat?: DateFormat;
}

interface CrosshairOptions {
  crosshairLineXColor?: string;
  crosshairLineYColor?: string;
  crosshairLabelXBgColor?: string;
  crosshairLabelYBgColor?: string;
}

interface HistogramOptions {
  scaleMargins?: {
    top: number;
    bottom: number;
  };
}

/**
 * @deprecated
 * Опции для разных типов графиков
 */
export interface ChartTypeOptions // todo: неверный интерфейс
  extends HistogramOptions,
    CrosshairOptions,
    TimescaleOptions,
    BaselineChartOptions,
    AreaChartOptions,
    LineChartOptions,
    CandlestickChartOptions {
  // Общие опции
  color?: string;
  lineWidth?: number;
}

export interface LineCandle {
  time: number;
  value: number;
  volume?: number;
}

export interface Candle {
  time: number;
  open: number;
  high: number;
  low: number;
  close: number;
  volume?: number; // todo: volume & future metaData to customFields
}

export type Destroyable = {
  destroy(): void;
};

export enum Direction {
  Top = 'top',
  Bottom = 'bottom',
  Left = 'left',
  Right = 'right',
}

// Настройки графика, которые могут изменяться в рантайме
export interface ChartOptionsModel {
  dateFormat: DateFormat;
  timeFormat: TimeFormat;
  showTime: boolean;
}