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


import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { UTCTimestamp } from 'lightweight-charts';

import { BehaviorSubject } from 'rxjs';

import { Candle, getStartTime, parseTimeframe, Timeframes } from '@lib';

// todo: import from @lib, then this commit will be published into moex-chart
import { normalizeSymbol } from '@src/utils';

dayjs.extend(duration);

class DataSourceProvider {
  // todo: add cache по `${symbol}${timeframe}`
  static instance: DataSourceProvider | null = null;
  private currentCandle: BehaviorSubject<Candle | null> = new BehaviorSubject<Candle | null>(null);
  private historicalStartDate: dayjs.Dayjs;

  private realtimeTimer: ReturnType<typeof setInterval> | null = null;
  private realtimeLastBySymbol = new Map<string, Candle>();

  constructor() {
    this.historicalStartDate = dayjs(Date.now()).subtract(5, 'year');
  }

  static getInstance(): DataSourceProvider {
    if (!this.instance) {
      this.instance = new DataSourceProvider();
    }
    return this.instance;
  }

  public startRealtime(
    getSymbols: () => string[],
    getTimeframe: () => Timeframes,
    update: (symbol: string, candle: Candle) => void,
    periodMs = 500,
  ): () => void {
    if (this.realtimeTimer) window.clearIntervalReliable(this.realtimeTimer);

    this.realtimeTimer = window.setIntervalReliable(() => {
      const tf = getTimeframe();
      const symbols = getSymbols();

      for (let i = 0; i < symbols.length; i += 1) {
        const symbol = normalizeSymbol(symbols[i]);
        if (!symbol) continue;

        update(symbol, this.nextRealtimeCandle(tf, symbol, periodMs));
      }
    }, periodMs);

    return () => {
      if (this.realtimeTimer) window.clearIntervalReliable(this.realtimeTimer);
      this.realtimeTimer = null;
    };
  }

  private nextRealtimeCandle(timeframe: Timeframes, symbol: string, realtimePeriodMs: number): Candle {
    const now = Date.now();
    const time = getStartTime(timeframe, now) as UTCTimestamp;

    console.log(
      '[DataSourceProvider:realtime:getStartTime]',
      JSON.stringify(
        {
          timeframe,
          symbol,
          now,
          nowIso: new Date(now).toISOString(),
          nowLocal: dayjs(now).format('YYYY-MM-DD HH:mm:ss Z'),
          timezoneOffsetMinutes: new Date().getTimezoneOffset(),
          result: time,
          resultIso: new Date(time * 1000).toISOString(),
          resultUtc: dayjs.unix(time).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          resultLocal: dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss Z'),
        },
        null,
        2,
      ),
    );

    const prev = this.realtimeLastBySymbol.get(symbol);
    const sameBar = !!prev && prev.time === time;

    const basePrice = sameBar ? prev.open : prev ? prev.close : 100 + Math.random() * 20;

    const open = sameBar ? prev.open : basePrice;
    const close = basePrice + ((Math.random() - 0.5) * 2) / 10;

    const high = Math.max(open, close) + Math.random() / 10;
    const low = Math.min(open, close) - Math.random() / 10;

    const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

    const candleDuration = dayjs.duration(candleWidth, dayjsUnit);

    const volume = Math.max(
      1,
      Math.floor((Math.random() * 1000000 + 100000) / (candleDuration.asMilliseconds() / realtimePeriodMs)),
    );

    const next: Candle = sameBar
      ? {
          time,
          open: prev.open,
          high: Math.max(prev.high, high),
          low: Math.min(prev.low, low),
          close,
          volume,
        }
      : {
          time,
          open,
          high,
          low,
          close,
          volume,
        };

    this.realtimeLastBySymbol.set(symbol, next);

    return next;
  }

  /**
   * Универсальная функция для генерации исторических свечей.
   * @param timeframe - Таймфрейм.
   * @param symbol - symbol.
   * @param untilCandle - (Опционально) Свеча, ДО которой нужно генерировать данные.
   */
  public async generateCandles(timeframe: Timeframes, _symbol: string, untilCandle?: Candle): Promise<Candle[] | null> {
    const data: Candle[] = [];
    const dataLength = 2000;

    const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

    const now = Date.now();
    const calculatedEndSec = getStartTime(timeframe, now) as number;
    const endSec = untilCandle ? untilCandle.time : calculatedEndSec;
    const endTime = dayjs.unix(endSec);

    console.log(
      '[DataSourceProvider:generateCandles:start]',
      JSON.stringify(
        {
          timeframe,
          symbol: _symbol,
          candleWidth,
          dayjsUnit,
          untilCandle: untilCandle
            ? {
                time: untilCandle.time,
                iso: new Date(untilCandle.time * 1000).toISOString(),
                utc: dayjs.unix(untilCandle.time).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
                local: dayjs.unix(untilCandle.time).format('YYYY-MM-DD HH:mm:ss Z'),
              }
            : null,
          now,
          nowIso: new Date(now).toISOString(),
          nowLocal: dayjs(now).format('YYYY-MM-DD HH:mm:ss Z'),
          timezoneOffsetMinutes: new Date().getTimezoneOffset(),
          calculatedEndSec,
          calculatedEndIso: new Date(calculatedEndSec * 1000).toISOString(),
          calculatedEndUtc: dayjs.unix(calculatedEndSec).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          calculatedEndLocal: dayjs.unix(calculatedEndSec).format('YYYY-MM-DD HH:mm:ss Z'),
          endSec,
          endIso: new Date(endSec * 1000).toISOString(),
          endUtc: endTime.utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          endLocal: endTime.format('YYYY-MM-DD HH:mm:ss Z'),
        },
        null,
        2,
      ),
    );

    const historicalStartInput = this.historicalStartDate.unix() * 1000;
    const historyStartUnix = getStartTime(timeframe, historicalStartInput);
    const historyStartTime = dayjs.unix(historyStartUnix);

    console.log(
      '[DataSourceProvider:generateCandles:historyStart]',
      JSON.stringify(
        {
          timeframe,
          historicalStartInput,
          historicalStartInputIso: new Date(historicalStartInput).toISOString(),
          historicalStartInputLocal: dayjs(historicalStartInput).format('YYYY-MM-DD HH:mm:ss Z'),
          historyStartUnix,
          historyStartIso: new Date(historyStartUnix * 1000).toISOString(),
          historyStartUtc: historyStartTime.utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          historyStartLocal: historyStartTime.format('YYYY-MM-DD HH:mm:ss Z'),
        },
        null,
        2,
      ),
    );

    if (untilCandle && untilCandle.time <= historyStartUnix) {
      return null;
    }

    const startTimeInput = endTime.subtract(candleWidth * dataLength, dayjsUnit).unix() * 1000;
    const calculatedStartTime = getStartTime(timeframe, startTimeInput);

    let startTime = dayjs(calculatedStartTime * 1000);

    console.log(
      '[DataSourceProvider:generateCandles:startTime]',
      JSON.stringify(
        {
          timeframe,
          startTimeInput,
          startTimeInputIso: new Date(startTimeInput).toISOString(),
          startTimeInputLocal: dayjs(startTimeInput).format('YYYY-MM-DD HH:mm:ss Z'),
          calculatedStartTime,
          calculatedStartTimeIso: new Date(calculatedStartTime * 1000).toISOString(),
          calculatedStartTimeUtc: dayjs.unix(calculatedStartTime).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          calculatedStartTimeLocal: dayjs.unix(calculatedStartTime).format('YYYY-MM-DD HH:mm:ss Z'),
        },
        null,
        2,
      ),
    );

    if (startTime.isBefore(historyStartTime)) {
      startTime = historyStartTime;
    }

    if (!startTime.isBefore(endTime)) {
      return [];
    }

    let basePrice = untilCandle?.open ?? 100 + Math.random() * 20;
    let currentTime = startTime.clone();

    const limit = untilCandle ? endTime : endTime.add(candleWidth, dayjsUnit);

    console.log(
      '[DataSourceProvider:generateCandles:range]',
      JSON.stringify(
        {
          timeframe,
          startTimeUnix: startTime.unix(),
          startTimeIso: new Date(startTime.unix() * 1000).toISOString(),
          startTimeUtc: startTime.utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          startTimeLocal: startTime.format('YYYY-MM-DD HH:mm:ss Z'),
          endTimeUnix: endTime.unix(),
          endTimeIso: new Date(endTime.unix() * 1000).toISOString(),
          endTimeUtc: endTime.utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          endTimeLocal: endTime.format('YYYY-MM-DD HH:mm:ss Z'),
          limitUnix: limit.unix(),
          limitIso: new Date(limit.unix() * 1000).toISOString(),
          limitUtc: limit.utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
          limitLocal: limit.format('YYYY-MM-DD HH:mm:ss Z'),
        },
        null,
        2,
      ),
    );

    while (currentTime.isBefore(limit)) {
      const time = currentTime.unix();

      const open = basePrice;
      const close = basePrice + (Math.random() - 0.5) * 2;
      const high = Math.max(open, close) + Math.random();
      const low = Math.min(open, close) - Math.random();
      const volume = Math.floor(Math.random() * 1000000) + 100000;

      data.push({
        time,
        open,
        high,
        low,
        close,
        volume,
      });

      basePrice = close;
      currentTime = currentTime.add(candleWidth, dayjsUnit);
    }

    console.log(
      '[DataSourceProvider:generateCandles:result]',
      JSON.stringify(
        {
          timeframe,
          symbol: _symbol,
          length: data.length,
          firstCandles: data.slice(0, 3).map((candle) => ({
            time: candle.time,
            iso: new Date(candle.time * 1000).toISOString(),
            utc: dayjs.unix(candle.time).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
            local: dayjs.unix(candle.time).format('YYYY-MM-DD HH:mm:ss Z'),
          })),
          lastCandles: data.slice(-3).map((candle) => ({
            time: candle.time,
            iso: new Date(candle.time * 1000).toISOString(),
            utc: dayjs.unix(candle.time).utc().format('YYYY-MM-DD HH:mm:ss [UTC]'),
            local: dayjs.unix(candle.time).format('YYYY-MM-DD HH:mm:ss Z'),
          })),
        },
        null,
        2,
      ),
    );

    await delay(300);

    if (this.currentCandle.value === null && data.length > 0) {
      this.currentCandle.next(data[data.length - 1]);
    }

    if(!untilCandle){
      this.realtimeLastBySymbol.set(_symbol, data[data.length - 1])
    }

    return data;
  }
}

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

export const dataSourceProvider = DataSourceProvider.getInstance();