Загрузка данных
import dayjs from 'dayjs';
import {
Coordinate,
IChartApi,
ISeriesApi,
LineSeries,
LogicalRange,
SeriesPartialOptionsMap,
Time,
WhitespaceData,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type { Intervals } from '@src/types/intervals';
import type { Timeframes } from '@src/types/timeframes';
type TimeStepUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
interface TimeStep {
unit: TimeStepUnit;
value: number;
key: string;
}
interface TimeScaleFuturePaddingParams {
chart: IChartApi;
mainSeries$: Observable<SeriesStrategies | null>;
timeframe$?: Observable<Timeframes | null>;
interval$?: Observable<Intervals | null>;
maxFutureBarsByUnit?: Partial<Record<TimeStepUnit, number>>;
}
const DAY_SECONDS = 86_400;
const MIN_FUTURE_BARS = 12;
const RESERVE_VISIBLE_PART = 0.35;
const DEFAULT_MAX_FUTURE_BARS_BY_UNIT: Record<TimeStepUnit, number> = {
second: 600,
minute: 720,
hour: 360,
day: 366,
week: 260,
month: 120,
year: 30,
};
export class TimeScaleFuturePadding {
private readonly chart: IChartApi;
private readonly paddingSeries: ISeriesApi<'Line'>;
private readonly subscriptions = new Subscription();
private readonly maxFutureBarsByUnit: Record<TimeStepUnit, number>;
private mainSeries: SeriesStrategies | null = null;
private currentTimeframe: Timeframes | null = null;
private paddingKey = '';
private isDestroyed = false;
constructor({
chart,
mainSeries$,
timeframe$,
interval$,
maxFutureBarsByUnit,
}: TimeScaleFuturePaddingParams) {
this.chart = chart;
this.maxFutureBarsByUnit = {
...DEFAULT_MAX_FUTURE_BARS_BY_UNIT,
...maxFutureBarsByUnit,
};
this.paddingSeries = chart.addSeries(LineSeries, this.getPaddingSeriesOptions());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.setMainSeries(series);
}),
);
if (timeframe$) {
this.subscriptions.add(
timeframe$.subscribe((timeframe) => {
this.currentTimeframe = timeframe;
this.updatePadding();
}),
);
}
if (interval$) {
this.subscriptions.add(
interval$.subscribe(() => {
this.updatePadding();
}),
);
}
}
public destroy(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.subscriptions.unsubscribe();
try {
this.chart.removeSeries(this.paddingSeries);
} catch {
this.paddingKey = '';
}
}
private getPaddingSeriesOptions(): SeriesPartialOptionsMap['Line'] {
return {
priceScaleId: '',
lineVisible: false,
pointMarkersVisible: false,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
title: '',
};
}
private setMainSeries(series: SeriesStrategies | null): void {
if (this.mainSeries === series) {
return;
}
this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
this.mainSeries = series;
this.mainSeries?.subscribeDataChanged(this.handleDataChanged);
this.updatePadding();
}
private handleDataChanged = (): void => {
this.updatePadding();
};
private handleVisibleLogicalRangeChange = (): void => {
this.updatePadding();
};
private updatePadding(): void {
if (this.isDestroyed) {
return;
}
if (!this.mainSeries) {
this.clearPadding();
return;
}
const sourceData = this.mainSeries.data();
if (!sourceData.length) {
this.clearPadding();
return;
}
const lastItem = sourceData[sourceData.length - 1];
if (!lastItem || typeof lastItem.time !== 'number' || !Number.isFinite(lastItem.time)) {
this.clearPadding();
return;
}
const timeStep = this.getTimeStep(sourceData);
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
const lastRealLogical = this.getLastRealLogicalIndex(lastItem.time, sourceData.length - 1);
const futureBarsCount = this.getFutureBarsCount(visibleRange, lastRealLogical, timeStep);
if (futureBarsCount <= 0) {
this.clearPadding();
return;
}
const nextPaddingKey = `${sourceData.length}:${lastItem.time}:${timeStep.key}:${futureBarsCount}`;
if (this.paddingKey === nextPaddingKey) {
return;
}
this.paddingKey = nextPaddingKey;
this.paddingSeries.setData(this.createPaddingData(lastItem.time, timeStep, futureBarsCount));
}
private clearPadding(): void {
if (!this.paddingKey) {
return;
}
this.paddingKey = '';
this.paddingSeries.setData([]);
}
private getLastRealLogicalIndex(time: Time, fallback: number): number {
const coordinate = this.chart.timeScale().timeToCoordinate(time);
if (coordinate === null) {
return fallback;
}
const logical = this.chart.timeScale().coordinateToLogical(coordinate as Coordinate);
if (logical === null || !Number.isFinite(logical)) {
return fallback;
}
return Number(logical);
}
private getFutureBarsCount(
visibleRange: LogicalRange | null,
lastRealLogical: number,
timeStep: TimeStep,
): number {
const maxFutureBars = this.maxFutureBarsByUnit[timeStep.unit];
if (!visibleRange) {
return Math.min(maxFutureBars, MIN_FUTURE_BARS);
}
const visibleBars = Math.max(1, Math.ceil(visibleRange.to - visibleRange.from));
const visibleFutureBars = Math.max(0, Math.ceil(visibleRange.to - lastRealLogical));
const reserveBars = Math.max(MIN_FUTURE_BARS, Math.ceil(visibleBars * RESERVE_VISIBLE_PART));
return Math.min(maxFutureBars, visibleFutureBars + reserveBars);
}
private createPaddingData(lastTime: number, timeStep: TimeStep, count: number): WhitespaceData<Time>[] {
const data: WhitespaceData<Time>[] = [];
for (let index = 1; index <= count; index += 1) {
data.push({
time: this.getNextTime(lastTime, timeStep, index),
});
}
return data;
}
private getNextTime(lastTime: number, timeStep: TimeStep, index: number): Time {
const stepValue = timeStep.value * index;
if (timeStep.unit === 'month' || timeStep.unit === 'year') {
return dayjs.unix(lastTime).add(stepValue, timeStep.unit).unix() as Time;
}
return (lastTime + this.getStepSeconds(timeStep) * index) as Time;
}
private getStepSeconds(timeStep: TimeStep): number {
switch (timeStep.unit) {
case 'second':
return timeStep.value;
case 'minute':
return timeStep.value * 60;
case 'hour':
return timeStep.value * 3_600;
case 'day':
return timeStep.value * DAY_SECONDS;
case 'week':
return timeStep.value * DAY_SECONDS * 7;
default:
return DAY_SECONDS;
}
}
private getTimeStep(data: readonly { time: Time }[]): TimeStep {
if (this.currentTimeframe) {
const timeStep = parseTimeframe(String(this.currentTimeframe));
if (timeStep) {
return timeStep;
}
}
return inferTimeStepFromData(data);
}
}
function parseTimeframe(timeframe: string): TimeStep | null {
const match = timeframe.match(/^(\d+)(s|m|h|d|w|M|y)$/);
if (!match) {
return null;
}
const value = Number(match[1]);
const unit = match[2];
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (unit === 's') {
return createTimeStep('second', value, timeframe);
}
if (unit === 'm') {
return createTimeStep('minute', value, timeframe);
}
if (unit === 'h') {
return createTimeStep('hour', value, timeframe);
}
if (unit === 'd') {
return createTimeStep('day', value, timeframe);
}
if (unit === 'w') {
return createTimeStep('week', value, timeframe);
}
if (unit === 'M') {
return createTimeStep('month', value, timeframe);
}
if (unit === 'y') {
return createTimeStep('year', value, timeframe);
}
return null;
}
function inferTimeStepFromData(data: readonly { time: Time }[]): TimeStep {
const times = data.reduce<number[]>((acc, item) => {
if (typeof item.time === 'number' && Number.isFinite(item.time)) {
acc.push(Number(item.time));
}
return acc;
}, []);
if (times.length < 2) {
return createTimeStep('minute', 1, 'inferred:minute:1');
}
const uniqueTimes = Array.from(new Set(times)).sort((a, b) => a - b);
const latestTimes = uniqueTimes.slice(-100);
const distances: number[] = [];
for (let index = 1; index < latestTimes.length; index += 1) {
const prevTime = latestTimes[index - 1];
const currentTime = latestTimes[index];
const distance = currentTime - prevTime;
if (distance > 0) {
distances.push(distance);
}
}
if (!distances.length) {
return createTimeStep('minute', 1, 'inferred:minute:1');
}
distances.sort((a, b) => a - b);
const medianDistance = distances[Math.floor(distances.length / 2)];
if (medianDistance < 60) {
const value = Math.max(1, Math.round(medianDistance));
return createTimeStep('second', value, `inferred:second:${value}`);
}
if (medianDistance < 3_600) {
const value = Math.max(1, Math.round(medianDistance / 60));
return createTimeStep('minute', value, `inferred:minute:${value}`);
}
if (medianDistance < DAY_SECONDS) {
const value = Math.max(1, Math.round(medianDistance / 3_600));
return createTimeStep('hour', value, `inferred:hour:${value}`);
}
if (medianDistance < DAY_SECONDS * 27) {
const value = Math.max(1, Math.round(medianDistance / DAY_SECONDS));
return createTimeStep('day', value, `inferred:day:${value}`);
}
if (medianDistance < DAY_SECONDS * 90) {
const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 30)));
return createTimeStep('month', value, `inferred:month:${value}`);
}
if (medianDistance < DAY_SECONDS * 365) {
const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 7)));
return createTimeStep('week', value, `inferred:week:${value}`);
}
const value = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 365)));
return createTimeStep('year', value, `inferred:year:${value}`);
}
function createTimeStep(unit: TimeStepUnit, value: number, key: string): TimeStep {
return {
unit,
value,
key,
};
}