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


import type { Candle } from '@src/types';

export type SeasonalityScaleMode = 'price' | 'percentage';

export interface SeasonalityPeriod {
  fromYear: number;
  toYear: number;
}

export interface SeasonalityPoint {
  time: string;
  originalTime: number;
  year: number;
  price: number;
  percentage: number | null;
}

export interface SeasonalityYearData {
  year: number;
  points: SeasonalityPoint[];
}

export interface SeasonalityHoverValue {
  year: number;
  price: number;
  percentage: number | null;
  originalTime: number;
}

export interface SeasonalityHoverData {
  time: string;
  values: SeasonalityHoverValue[];
}

export type SeasonalityPageLoader = (
  symbolId: string,
  until?: Candle,
) => Promise<Candle[] | null>;

export interface SeasonalityLoadParams extends SeasonalityPeriod {
  symbolId: string;
  signal?: AbortSignal;
}

















import type { Candle } from '@src/types';

import type {
  SeasonalityPeriod,
  SeasonalityPoint,
  SeasonalityYearData,
} from './types';

const REFERENCE_YEAR = 2000;
const DEFAULT_TIME_ZONE = 'Europe/Moscow';

interface TradingDate {
  year: number;
  month: number;
  day: number;
}

interface PricePoint {
  time: number;
  price: number;
}

export class SeasonalityCalculator {
  private readonly dateFormatter: Intl.DateTimeFormat;

  constructor(timeZone = DEFAULT_TIME_ZONE) {
    this.dateFormatter = new Intl.DateTimeFormat('en-GB', {
      timeZone,
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
    });
  }

  public calculate(
    candles: readonly Candle[],
    period: SeasonalityPeriod,
  ): SeasonalityYearData[] {
    this.validatePeriod(period);

    const grouped = new Map<number, Map<string, PricePoint>>();

    const sortedCandles = [...candles].sort(
      (first, second) => first.time - second.time,
    );

    for (const candle of sortedCandles) {
      if (!this.isValidCandle(candle)) {
        continue;
      }

      const date = this.getTradingDate(candle.time);

      if (!date) {
        continue;
      }

      if (
        date.year < period.fromYear ||
        date.year > period.toYear
      ) {
        continue;
      }

      const calendarDate = this.getCalendarDate(date);

      let yearPoints = grouped.get(date.year);

      if (!yearPoints) {
        yearPoints = new Map<string, PricePoint>();
        grouped.set(date.year, yearPoints);
      }

      yearPoints.set(calendarDate, {
        time: candle.time,
        price: candle.close,
      });
    }

    return [...grouped.entries()]
      .sort(([firstYear], [secondYear]) => firstYear - secondYear)
      .map(([year, points]) => ({
        year,
        points: this.calculateYear(year, points),
      }));
  }

  private calculateYear(
    year: number,
    points: Map<string, PricePoint>,
  ): SeasonalityPoint[] {
    const sortedPoints = [...points.entries()].sort(
      ([firstDate], [secondDate]) =>
        firstDate.localeCompare(secondDate),
    );

    if (sortedPoints.length === 0) {
      return [];
    }

    const basePrice = sortedPoints[0][1].price;

    return sortedPoints.map(([time, point]) => ({
      time,
      originalTime: point.time,
      year,
      price: point.price,
      percentage:
        basePrice !== 0
          ? (point.price / basePrice - 1) * 100
          : null,
    }));
  }

  private getTradingDate(timestamp: number): TradingDate | null {
    const date = new Date(timestamp * 1000);

    if (!Number.isFinite(date.getTime())) {
      return null;
    }

    const parts = this.dateFormatter.formatToParts(date);

    const getPart = (type: Intl.DateTimeFormatPartTypes): number => {
      const value = parts.find((part) => part.type === type);
      return Number(value?.value);
    };

    return {
      year: getPart('year'),
      month: getPart('month'),
      day: getPart('day'),
    };
  }

  private getCalendarDate(date: TradingDate): string {
    const month = String(date.month).padStart(2, '0');
    const day = String(date.day).padStart(2, '0');

    return `${REFERENCE_YEAR}-${month}-${day}`;
  }

  private isValidCandle(candle: Candle): boolean {
    return (
      Number.isFinite(candle.time) &&
      typeof candle.close === 'number' &&
      Number.isFinite(candle.close)
    );
  }

  private validatePeriod(period: SeasonalityPeriod): void {
    if (
      !Number.isInteger(period.fromYear) ||
      !Number.isInteger(period.toYear) ||
      period.fromYear > period.toYear
    ) {
      throw new RangeError('Некорректный диапазон лет');
    }
  }
}