Загрузка данных
import { type LineData, PriceScaleMode, type WhitespaceData } from 'lightweight-charts';
import type {
DailyClose,
SeasonalityChange,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
type SeriesDatum = LineData | WhitespaceData;
const CALENDAR = Array.from({ length: 366 }, (_, day) =>
new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);
export function normalizeRange(range: SeasonalityRange, minYear: number, maxYear: number): SeasonalityRange {
if (!Number.isFinite(range.fromYear) || !Number.isFinite(range.toYear)) {
throw new Error('Invalid year range');
}
const fromYear = Math.max(minYear, Math.min(maxYear, Math.round(range.fromYear)));
const toYear = Math.max(fromYear, Math.min(maxYear, Math.round(range.toYear)));
return { fromYear, toYear };
}
export function buildSeasonality(rows: readonly DailyClose[]): SeasonalityYear[] {
const years: SeasonalityYear[] = [];
const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date));
for (const row of sorted) {
const year = Number(row.date.slice(0, 4));
let current = years[years.length - 1];
if (!current || current.year !== year) {
current = {
year,
baselineDate: row.date,
baselinePrice: row.close,
points: [],
};
years.push(current);
}
if (current.points[current.points.length - 1]?.date === row.date) {
throw new Error(`Multiple daily candles on ${row.date}`);
}
const percentage = current.baselinePrice > 0 ? (row.close / current.baselinePrice - 1) * 100 : null;
if (percentage !== null && !Number.isFinite(percentage)) {
throw new Error('Invalid percentage');
}
current.points.push({
date: row.date,
chartDate: `2000-${row.date.slice(5)}`,
price: row.close,
percentage,
});
}
return years;
}
export function toSeriesData(year: SeasonalityYear, scale: SeasonalityScale): SeriesDatum[] {
const isPercentage = scale === PriceScaleMode.Percentage;
let index = 0;
return CALENDAR.map((time) => {
const point = year.points[index];
if (point?.chartDate !== time) {
return { time };
}
index += 1;
const value = isPercentage ? point.percentage : point.price;
return value === null ? { time } : { time, value };
});
}
export function buildSeasonalityTable(years: readonly SeasonalityYear[]): SeasonalityTableRow[] {
const closes = new Map<number, number>();
for (const year of years) {
for (const point of year.points) {
const month = Number(point.date.slice(5, 7)) - 1;
closes.set(year.year * 12 + month, point.price);
}
}
return years.map((year) => {
const firstMonth = year.year * 12;
const lastPoint = year.points[year.points.length - 1];
const baselinePrice = closes.get(firstMonth - 1) ?? year.baselinePrice;
return {
year: year.year,
months: Array.from({ length: 12 }, (_, month) => {
const key = firstMonth + month;
return calculateChange(closes.get(key - 1), closes.get(key));
}),
total: calculateChange(baselinePrice, lastPoint.price),
};
}).reverse();
}
function calculateChange(from: number | undefined, to: number | undefined): SeasonalityChange | null {
if (from === undefined || to === undefined) {
return null;
}
return {
absolute: to - from,
percentage: from > 0 ? (to / from - 1) * 100 : null,
};
}