Загрузка данных
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('Некорректный диапазон лет');
}
}
}