Загрузка данных
import { type BehaviorSubject, distinctUntilChanged, type Observable, Subscription } from 'rxjs';
import { MAIN_PANE_INDEX } from '@src/constants';
import { type Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
import type { DataSource } from '@core/DataSource';
import type { Indicator } from '@core/Indicator';
import type { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import type { Ohlc } from '@core/Legend';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type {
BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
CustomData,
DataChangedHandler,
DeepPartial,
HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
IPriceLine,
IPriceScaleApi,
IRange,
ISeriesApi,
ISeriesPrimitive,
LineData,
MismatchDirection,
MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesOptionsMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
export interface SerieData {
time: Time;
customValues: Candle;
}
export interface CreateSeriesParams<TSeries extends SeriesType> {
chart: IChartApi;
seriesOptions?: SeriesPartialOptionsMap[TSeries];
paneIndex?: number;
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}
export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
getLwcSeries: () => ISeriesApi<TSeries>;
getLegendData: (param?: MouseEventParams) => Partial<
Record<
keyof Ohlc,
{
value: number | string | Time;
color: string;
name: string;
}
>
>;
}
export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
lwcChart: IChartApi;
dataSource: DataSource;
mainSymbolId$: Observable<string>;
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
seriesOptions?: SeriesPartialOptionsMap[TSeries];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
paneIndex?: number;
indicatorReference?: Indicator;
}
function debugLog(label: string, value: unknown): void {
console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}
function debugTime(time: unknown) {
if (typeof time !== 'number' || !Number.isFinite(time)) {
return {
time,
type: typeof time,
};
}
const secondsDate = new Date(time * 1000);
const millisecondsDate = new Date(time);
return {
time,
likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
asSeconds: Number.isNaN(secondsDate.getTime()) ? 'Invalid Date' : secondsDate.toISOString(),
asMilliseconds: Number.isNaN(millisecondsDate.getTime())
? 'Invalid Date'
: millisecondsDate.toISOString(),
};
}
function debugSeriesData(label: string, data: readonly { time: Time }[]): void {
debugLog(label, {
count: data.length,
first: data.slice(0, 8).map(({ time }) => debugTime(time)),
last: data.slice(-8).map(({ time }) => debugTime(time)),
});
}
function applyLocalTimezone(candles: Candle[]): Candle[] {
// todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
return candles.flatMap((candle) => {
if (typeof candle.time !== 'number' || !Number.isFinite(candle.time)) {
return [];
}
const offsetSeconds = new Date(candle.time * 1000).getTimezoneOffset() * 60;
return [
{
...candle,
time: candle.time - offsetSeconds,
},
];
});
}
export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
protected lwcSeries: ISeriesApi<TSeries>;
protected customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
protected lwcChart: IChartApi;
protected mainSymbolId$: Observable<string>;
protected mainSymbol$: Observable<string>;
protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
protected paneIndex: number | null = null;
protected indicatorReference: Indicator | null = null;
protected showSymbolLabel: boolean;
private subscriptions = new Subscription();
private dataSub: Subscription | null = null;
private realtimeSub: Subscription | null = null;
constructor({
lwcChart,
mainSymbolId$,
mainSymbol$,
mainSerie$,
customFormatter,
seriesOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
indicatorReference,
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
seriesOptions,
paneIndex,
priceScaleOptions,
});
this.lwcChart = lwcChart;
this.customFormatter = customFormatter;
this.mainSymbolId$ = mainSymbolId$;
this.mainSymbol$ = mainSymbol$;
this.mainSerie$ = mainSerie$;
this.showSymbolLabel = showSymbolLabel;
this.indicatorReference = indicatorReference ?? null;
debugLog('[TIME DEBUG][BaseSeries][created]', {
seriesType: this.lwcSeries.seriesType(),
});
}
public getLegendData = (
param?: MouseEventParams,
): Partial<
Record<
keyof Ohlc,
{
value: number | string | Time;
color: string;
name: string;
}
>
> => {
if (!param) {
const seriesData = this.data();
const currentBar = seriesData[seriesData.length - 1];
if (!currentBar) {
return {};
}
return this.formatLegendValues(currentBar, seriesData[seriesData.length - 2] ?? null);
}
const currentBar = param.seriesData.get(this.lwcSeries) ?? null;
const previousBar = param.logical === null ? null : this.dataByIndex(param.logical! - 1);
return this.formatLegendValues(currentBar, previousBar);
};
public show(): void {
this.lwcSeries.applyOptions({
visible: true,
});
}
public hide(): void {
this.lwcSeries.applyOptions({
visible: false,
});
}
public isVisible(): boolean {
return this.lwcSeries.options().visible;
}
public destroy(): void {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.subscriptions.unsubscribe();
this.lwcChart.removeSeries(this.lwcSeries);
}
public getLwcSeries(): ISeriesApi<TSeries> {
return this.lwcSeries;
}
public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
this.lwcSeries.applyOptions(options);
}
public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.attachPrimitive(primitive);
}
public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
return this.lwcSeries.barsInLogicalRange(range);
}
public coordinateToPrice(coordinate: number): BarPrice | null {
return this.lwcSeries.coordinateToPrice(coordinate);
}
public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
return this.lwcSeries.createPriceLine(options);
}
public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
return this.lwcSeries.data();
}
public dataByIndex(
logicalIndex: number,
mismatchDirection?: MismatchDirection,
): SeriesDataItemTypeMap<Time>[TSeries] | null {
return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
}
public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.detachPrimitive(primitive);
}
public getPane(): IPaneApi<Time> {
return this.lwcSeries.getPane();
}
public moveToPane(paneIndex: number): void {
this.lwcSeries.moveToPane(paneIndex);
}
public options(): Readonly<SeriesOptionsMap[TSeries]> {
return this.lwcSeries.options();
}
public priceFormatter(): IPriceFormatter {
return this.lwcSeries.priceFormatter();
}
public priceLines(): IPriceLine[] {
return this.lwcSeries.priceLines();
}
public priceScale(): IPriceScaleApi {
return this.lwcSeries.priceScale();
}
public priceToCoordinate(price: number): Coordinate | null {
return this.lwcSeries.priceToCoordinate(price);
}
public removePriceLine(line: IPriceLine): void {
this.lwcSeries.removePriceLine(line);
}
public seriesOrder(): number {
return this.lwcSeries.seriesOrder();
}
public seriesType(): TSeries {
return this.lwcSeries.seriesType();
}
public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
const normalizedData = normalizeSeriesData(data);
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][LWC setData]`,
normalizedData,
);
this.lwcSeries.setData(normalizedData);
}
public setSeriesOrder(order: number): void {
this.lwcSeries.setSeriesOrder(order);
}
public subscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.subscribeDataChanged(handler);
}
public unsubscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.unsubscribeDataChanged(handler);
}
public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][LWC update]`,
[bar],
);
const data = this.lwcSeries.data();
const lastBar = data[data.length - 1];
if (!lastBar) {
this.lwcSeries.update(bar, false);
return;
}
const isHistoricalUpdate =
historicalUpdate ??
(typeof lastBar.time === 'number' &&
typeof bar.time === 'number' &&
bar.time < lastBar.time);
this.lwcSeries.update(bar, isHistoricalUpdate);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const options = {
...this.getDefaultOptions(),
...seriesOptions,
};
const series = chart.addSeries<TSeries>(this.seriesDefinition(), options, paneIndex);
chart.priceScale(options.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);
return series;
}
protected abstract dataSourceSubscription(next: Candle[]): void;
protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
protected abstract dataSourceRealtimeSubscription(next: Candle): void;
protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
protected abstract formatLegendValues(
currentBar: BarData | LineData | HistogramData | CustomData | null,
prevBar: BarData | LineData | HistogramData | CustomData | null,
): Partial<
Record<
keyof Ohlc,
{
value: number | string | Time;
color: string;
name: string;
}
>
>;
protected applyTimezone(data: Candle[]): Candle[] {
return applyLocalTimezone(data);
}
protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][formatData input]`,
inputData,
);
const data = this.applyTimezone(inputData);
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][after timezone]`,
data,
);
if (!this.customFormatter) {
const formattedData = this.formatMainSerie(data);
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][after formatMainSerie]`,
formattedData,
);
return formattedData;
}
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
const selfData = this.data() as unknown as ChartTypeToCandleData[TSeries][];
if (data.length !== 1) {
const formattedData = this.customFormatter({
mainSeriesData,
selfData,
indicatorReference: this.indicatorReference ?? undefined,
});
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][after customFormatter]`,
formattedData,
);
return formattedData;
}
const candle = this.formatMainSerie(data)[0];
if (!candle) {
return [];
}
const formattedData = this.customFormatter({
mainSeriesData,
selfData,
candle: candle as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
debugSeriesData(
`[TIME DEBUG][BaseSeries][${this.seriesType()}][after customFormatter realtime]`,
formattedData,
);
return formattedData;
}
protected subscribeDataSource = (dataSource: DataSource): void => {
const minMove = getPricePrecisionStep();
this.lwcSeries.applyOptions({
priceFormat: {
type: 'custom',
minMove,
formatter: (price: number) => formatPrice(price) ?? String(price),
},
});
this.subscriptions.add(
this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
this.lwcSeries.applyOptions({
title: this.showSymbolLabel ? symbol : '',
});
}),
);
this.subscriptions.add(
this.mainSymbolId$.pipe(distinctUntilChanged()).subscribe((symbolId) => {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.dataSub = dataSource.subscribe(symbolId, (next) => {
this.dataSourceSubscription(next);
});
this.realtimeSub = dataSource.subscribeRealtime(symbolId, (next: Candle) => {
this.dataSourceRealtimeSubscription(next);
});
}),
);
};
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
):
| (Ohlc & {
customValues?: Record<string, unknown>;
})
| null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (isBarData(prev) && isBarData(current)) {
const absoluteChange = current.close - prev.close;
const percentageChange = ((current.close - prev.close) / prev.close) * 100;
return {
...current,
absoluteChange,
percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
};
}
if (isLineData(prev) && isLineData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
const high = current.customValues?.high;
const low = current.customValues?.low;
return {
time: current.time,
value: current.value,
high: typeof high === 'number' ? high : current.value,
low: typeof low === 'number' ? low : current.value,
absoluteChange,
percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
customValues: current.customValues,
};
}
return null;
}
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from 'dayjs/plugin/utc';
import 'dayjs/locale/ru';
import { TickMarkType, UTCTimestamp } from 'lightweight-charts';
import { TimeFormat, Timeframes } from '@src/types';
// плагины для работы с UTC и локализацией
dayjs.extend(utc);
dayjs.extend(localizedFormat);
dayjs.extend(duration);
const TIME_SCALE_DEBUG_LIMIT = 100;
const FORMAT_DATE_DEBUG_LIMIT = 50;
let timeScaleDebugCount = 0;
let formatDateDebugCount = 0;
function debugLog(label: string, value: unknown): void {
console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}
export enum DateFormat {
DOW_Q_YY = 'Mon Q3 \'97',
DOW_Q_YYYY = 'Mon Q3 1997',
DOW_D_MMM_YY = 'Mon 29 Sep \'97',
DOW_MMM_YY = 'Mon Sep \'97',
DOW_MMM_D_YYYY = 'Mon Sep 29, 1997',
DOW_MMM_YYYY = 'Mon Sep 1997',
DOW_MMM_D = 'Mon Sep 29',
DOW_D_MMM = 'Mon 29 Sep',
DOW_YYYY_MM_DD_DASH = 'Mon 1997-09-29',
DOW_YY_MM_DD_DASH = 'Mon 97-09-29',
DOW_YY_MM_DD_SLASH = 'Mon 97/09/29',
DOW_YYYY_MM_DD_SLASH = 'Mon 1997/09/29',
DOW_DD_MM_YYYY_DASH = 'Mon 29-09-1997',
DOW_DD_MM_YY_DASH = 'Mon 29-09-97',
DOW_DD_MM_YY_SLASH = 'Mon 29/09/97',
DOW_DD_MM_YYYY_SLASH = 'Mon 29/09/1997',
DOW_MM_DD_YY_SLASH = 'Mon 09/29/97',
DOW_MM_DD_YYYY_SLASH = 'Mon 09/29/1997',
DD_MM_YYYY_HH_mm_ss = '09.29.1997 00:00:00',
}
export const dateFormatOptions = Object.entries(DateFormat).map(([_, value]) => ({
label: value,
value,
}));
const customTimeFormatter = (
time: UTCTimestamp,
timeFormat: string,
locale: string,
) => {
const d = dayjs.unix(time).utc().locale(locale);
if (timeFormat === '12h') {
return d.format('h:mm:ss A'); // 12-часовой формат
}
return d.format('HH:mm:ss'); // 24-часовой формат
};
export function shouldShowTime(tf: Timeframes): boolean {
return !(
tf.endsWith('d') ||
tf.endsWith('w') ||
tf.endsWith('M') ||
tf.endsWith('М') ||
tf.endsWith('Y')
);
}
/**
* Форматирует UTC timestamp в строку согласно выбранному формату.
* @param time - UTCTimestamp (секунды)
* @param format - Значение из enum DateFormat
* @param timeFormat - Значение из enum TimeFormat
* @param showTime - Отображать ли время в строке даты
* @param locale - Языковая локаль (например, 'en-US', 'ru-RU')
* @returns Отформатированная строка с датой
*/
export function formatDate(
time: UTCTimestamp,
format: DateFormat,
timeFormat: TimeFormat,
showTime = true,
locale = 'ru-RU',
): string {
const d = dayjs.unix(time).utc().locale(locale);
if (formatDateDebugCount < FORMAT_DATE_DEBUG_LIMIT) {
debugLog('[TIME DEBUG][formatDate]', {
time,
iso: new Date(time * 1000).toISOString(),
utcFormatted: d.format('DD.MM.YYYY HH:mm:ss'),
browserLocal: new Date(time * 1000).toString(),
format,
timeFormat,
showTime,
});
formatDateDebugCount += 1;
}
const findPart = (type: string) => d.format(type);
let dateString: string;
switch (format) {
case DateFormat.DOW_Q_YY: {
const quarter = Math.floor(d.month() / 3) + 1;
dateString = `${findPart('ddd')} Q${quarter} '${d.format('YY')}`;
break;
}
case DateFormat.DOW_Q_YYYY: {
const quarter = Math.floor(d.month() / 3) + 1;
dateString = `${findPart('ddd')} Q${quarter} ${d.format('YYYY')}`;
break;
}
case DateFormat.DOW_D_MMM_YY:
dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')} '${d.format('YY')}`;
break;
case DateFormat.DOW_MMM_YY:
dateString = `${findPart('ddd')} ${d.format('MMM')} '${d.format('YY')}`;
break;
case DateFormat.DOW_MMM_D_YYYY:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}, ${d.format('YYYY')}`;
break;
case DateFormat.DOW_MMM_YYYY:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.format('YYYY')}`;
break;
case DateFormat.DOW_MMM_D:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}`;
break;
case DateFormat.DOW_D_MMM:
dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')}`;
break;
case DateFormat.DOW_YYYY_MM_DD_DASH:
dateString = `${findPart('ddd')} ${d.format('YYYY-MM-DD')}`;
break;
case DateFormat.DOW_YY_MM_DD_DASH:
dateString = `${findPart('ddd')} ${d.format('YY-MM-DD')}`;
break;
case DateFormat.DOW_YY_MM_DD_SLASH:
dateString = `${findPart('ddd')} ${d.format('YY/MM/DD')}`;
break;
case DateFormat.DOW_YYYY_MM_DD_SLASH:
dateString = `${findPart('ddd')} ${d.format('YYYY/MM/DD')}`;
break;
case DateFormat.DOW_DD_MM_YYYY_DASH:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
break;
case DateFormat.DOW_DD_MM_YY_DASH:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YY')}`;
break;
case DateFormat.DOW_DD_MM_YY_SLASH:
dateString = `${findPart('ddd')} ${d.format('DD/MM/YY')}`;
break;
case DateFormat.DOW_DD_MM_YYYY_SLASH:
dateString = `${findPart('ddd')} ${d.format('DD/MM/YYYY')}`;
break;
case DateFormat.DOW_MM_DD_YY_SLASH:
dateString = `${findPart('ddd')} ${d.format('MM/DD/YY')}`;
break;
case DateFormat.DOW_MM_DD_YYYY_SLASH:
dateString = `${findPart('ddd')} ${d.format('MM/DD/YYYY')}`;
break;
case DateFormat.DD_MM_YYYY_HH_mm_ss:
dateString = d.format('DD.MM.YYYY');
break;
default:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
}
if (!showTime) {
return dateString;
}
const timeString = customTimeFormatter(time, timeFormat, locale);
return `${dateString} ${timeString}`;
}
export function createTickMarkFormatter(
timeFormatString: string,
locale = 'ru-RU',
): (time: UTCTimestamp, tickMarkType: TickMarkType) => string {
return (time, tickMarkType) => {
const d = dayjs.unix(time).utc().locale(locale);
let formatted: string;
switch (tickMarkType) {
case TickMarkType.Year:
formatted = d.format('YYYY');
break;
case TickMarkType.Month:
formatted = d.format('MMM');
break;
case TickMarkType.DayOfMonth:
formatted = d.format('DD');
break;
case TickMarkType.Time:
formatted = d.format(timeFormatString);
break;
default:
formatted = '';
}
if (timeScaleDebugCount < TIME_SCALE_DEBUG_LIMIT) {
debugLog('[TIME DEBUG][TimeScale][tickMarkFormatter]', {
time,
iso: new Date(time * 1000).toISOString(),
browserLocal: new Date(time * 1000).toString(),
tickMarkType,
timeFormatString,
formatted,
});
timeScaleDebugCount += 1;
}
return formatted;
};
}
export function formatUtcOffset(date = dayjs()): string {
const offsetMinutes = date.utcOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
if (minutes === 0) {
return `${sign}${hours}`;
}
return `${sign}${hours}:${String(minutes).padStart(2, '0')}`;
}
export function formatDisplayText(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
) {
return String(value);
}
if (
typeof value === 'object' &&
'year' in value &&
'month' in value &&
'day' in value
) {
const businessDay = value as {
year: number;
month: number;
day: number;
};
return `${businessDay.year}.${String(businessDay.month).padStart(2, '0')}.${String(businessDay.day).padStart(2, '0')}`;
}
return String(value);
}
import type { Candle as MoexChartCandle } from 'moex-chart';
import type { Candle as ApiCandle } from 'types/Candles';
const CANDLE_DEBUG_LIMIT = 100;
let candleDebugCount = 0;
function debugLog(label: string, value: unknown): void {
console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}
const parseUtcTimestamp = (time: string): number => {
const normalizedTime = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(time)
? time
: `${time}Z`;
return Math.floor(Date.parse(normalizedTime) / 1000);
};
export const candleToBar = ({
open,
close,
high,
low,
volume,
begin,
end,
}: ApiCandle): MoexChartCandle => {
const time = parseUtcTimestamp(begin);
if (candleDebugCount < CANDLE_DEBUG_LIMIT) {
debugLog('[TIME DEBUG][candleToBar]', {
source: {
begin,
end,
},
result: {
time,
likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
normalizedUtc: new Date(time * 1000).toISOString(),
browserLocal: new Date(time * 1000).toString(),
timezoneOffsetMinutes: new Date(time * 1000).getTimezoneOffset(),
},
});
candleDebugCount += 1;
}
return {
open,
close,
high,
low,
// TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
// В котировках volume и value значения всегда null
volume: volume ?? 0,
time,
};
};
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { parseTimeframe } from 'moex-chart';
import {
moexChartTimeConverter,
moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
interface HistoryRequestState {
untilTime?: number;
request: Promise<Candle[] | null> | null;
}
function debugLog(label: string, value: unknown): void {
console.log(`${label}\n${JSON.stringify(value, null, 2)}`);
}
function debugCandleTime(time: number) {
const secondsDate = new Date(time * 1000);
const millisecondsDate = new Date(time);
return {
time,
likelyUnit: time > 10_000_000_000 ? 'milliseconds' : 'seconds',
asSeconds: Number.isNaN(secondsDate.getTime())
? 'Invalid Date'
: secondsDate.toISOString(),
localFromSeconds: Number.isNaN(secondsDate.getTime())
? 'Invalid Date'
: secondsDate.toString(),
asMilliseconds: Number.isNaN(millisecondsDate.getTime())
? 'Invalid Date'
: millisecondsDate.toISOString(),
};
}
function debugCandles(label: string, candles: Candle[]): void {
debugLog(label, {
count: candles.length,
first: candles.slice(0, 8).map((candle) => ({
...debugCandleTime(candle.time),
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
})),
last: candles.slice(-8).map((candle) => ({
...debugCandleTime(candle.time),
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
})),
});
}
function getRequestSymbol(symbolRaw?: string): string | undefined {
const symbol = String(symbolRaw ?? '').trim();
if (!symbol || symbol === DEFAULT_SYMBOL) {
return undefined;
}
return symbol;
}
function getTimeframeSeconds(timeframe: Timeframes): number {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}
function aggregateCandles(
candles: Candle[],
time: number,
): Candle | undefined {
const firstCandle = candles[0];
const lastCandle = candles[candles.length - 1];
if (!firstCandle || !lastCandle) {
return undefined;
}
return {
time,
open: firstCandle.open,
high: Math.max(...candles.map(({ high }) => high)),
low: Math.min(...candles.map(({ low }) => low)),
close: lastCandle.close,
volume: candles.reduce(
(total, candle) => total + (candle.volume ?? 0),
0,
),
};
}
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private prevRealtimeDataArr: Candle[] = [];
private prevRealtimeData: Candle | undefined;
private realtimeShouldBeConvoluted = false;
private realtimeSessionStart: number | null = null;
private realtimeTimer: ReturnType<typeof setInterval> | null = null;
private historyRequests = new Map<string, HistoryRequestState>();
public getDataSource =
(
indicativeData?: ChartIndicativeData,
cb?: (timeframe: Timeframes) => void,
) =>
async (
timeframe: Timeframes,
symbolId: string,
until?: Candle,
): Promise<Candle[] | null> => {
const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return null;
}
debugLog('[TIME DEBUG][DataSourceProvider][getDataSource]', {
timeframe,
symbolId,
symbol,
until: until
? debugCandleTime(until.time)
: null,
});
const historyRequestKey = `${symbol}:${timeframe}`;
const historyRequestState =
this.historyRequests.get(historyRequestKey);
if (
historyRequestState &&
historyRequestState.untilTime === until?.time
) {
if (historyRequestState.request) {
debugLog(
'[TIME DEBUG][DataSourceProvider][reuse history request]',
{
historyRequestKey,
untilTime: historyRequestState.untilTime,
},
);
return historyRequestState.request;
}
if (until) {
return null;
}
}
cb?.(timeframe);
const historyRequest = this.requestHistoryData({
timeframe,
symbol,
until,
indicativeData,
});
this.historyRequests.set(historyRequestKey, {
untilTime: until?.time,
request: historyRequest,
});
try {
return await historyRequest;
} finally {
if (
this.historyRequests.get(historyRequestKey)?.request ===
historyRequest
) {
if (until) {
this.historyRequests.set(historyRequestKey, {
untilTime: until.time,
request: null,
});
} else {
this.historyRequests.delete(historyRequestKey);
}
}
}
};
public startRealtime({
getSymbols,
getTimeframe,
update,
periodMs = 5000,
indicativeData,
}: {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
update: (symbolId: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData;
}): () => void {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = setInterval(() => {
const timeframe = getTimeframe();
const symbolIds = getSymbols();
Promise.all(
symbolIds.map(async (symbolId) => {
const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return;
}
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (!data) {
return;
}
debugCandles(
`[TIME DEBUG][DataSourceProvider][realtime raw][${timeframe}]`,
[data],
);
if (!this.realtimeShouldBeConvoluted) {
this.prevRealtimeData = data;
debugCandles(
`[TIME DEBUG][DataSourceProvider][realtime direct update][${timeframe}]`,
[data],
);
update(symbol, data);
return;
}
if (
this.prevRealtimeData &&
JSON.stringify(data) === JSON.stringify(this.prevRealtimeData)
) {
return;
}
this.realtimeConvolution(timeframe, data, (candle) => {
debugCandles(
`[TIME DEBUG][DataSourceProvider][realtime after convolution][${timeframe}]`,
[candle],
);
update(symbol, candle);
});
}),
);
}, periodMs);
return () => {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
};
}
private async requestHistoryData({
timeframe,
symbol,
until,
indicativeData,
}: {
timeframe: Timeframes;
symbol: string;
until?: Candle;
indicativeData?: ChartIndicativeData;
}): Promise<Candle[] | null> {
const interval = moexChartTimeConverter(timeframe);
const date = until?.time || Math.round(Date.now() / 1000);
debugLog('[TIME DEBUG][DataSourceProvider][request]', {
timeframe,
interval,
symbol,
until: until
? debugCandleTime(until.time)
: null,
requestTo: debugCandleTime(date),
});
const data = await requestBars({
currencyPair: symbol.replaceAll(':', '.'),
interval,
periodParams: {
firstDataRequest: true,
to: date,
from: Math.round(Date.now() / 1000),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
debugCandles(
`[TIME DEBUG][DataSourceProvider][requestBars result][${timeframe}]`,
data,
);
if (data.length === 0) {
return null;
}
const issTimeframe = moexChartToIssTimeframe(timeframe);
debugLog('[TIME DEBUG][DataSourceProvider][timeframes]', {
requestedTimeframe: timeframe,
issTimeframe,
shouldConvolute: issTimeframe !== timeframe,
});
if (issTimeframe === timeframe) {
this.realtimeShouldBeConvoluted = false;
if (!until) {
this.prevRealtimeData = data[data.length - 1];
this.prevRealtimeDataArr = [];
this.realtimeSessionStart = null;
}
debugCandles(
`[TIME DEBUG][DataSourceProvider][history direct][${timeframe}]`,
data,
);
return data;
}
this.realtimeShouldBeConvoluted = true;
const convolutedData = this.timeframeConvolution(
data,
timeframe,
!until,
);
debugCandles(
`[TIME DEBUG][DataSourceProvider][after convolution][${timeframe}]`,
convolutedData,
);
return convolutedData;
}
private timeframeConvolution(
data: Candle[],
requestedTimeframe: Timeframes,
syncRealtime: boolean,
): Candle[] {
const timeframeSeconds =
getTimeframeSeconds(requestedTimeframe);
const sortedData = [...data].sort(
(first, second) => first.time - second.time,
);
const firstCandle = sortedData[0];
if (!firstCandle) {
return [];
}
debugLog(
`[TIME DEBUG][DataSourceProvider][convolution config][${requestedTimeframe}]`,
{
requestedTimeframe,
timeframeSeconds,
firstCandle: debugCandleTime(firstCandle.time),
syncRealtime,
},
);
debugCandles(
`[TIME DEBUG][DataSourceProvider][convolution source][${requestedTimeframe}]`,
sortedData,
);
const result: Candle[] = [];
let sessionStart = firstCandle.time;
let bucketStart = sessionStart;
let candleGroup: Candle[] = [];
sortedData.forEach((candle, index) => {
const previousCandle = sortedData[index - 1];
const isNewSession =
previousCandle &&
candle.time - previousCandle.time > timeframeSeconds;
if (isNewSession) {
const aggregatedCandle = aggregateCandles(
candleGroup,
bucketStart,
);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
debugLog(
`[TIME DEBUG][DataSourceProvider][new session][${requestedTimeframe}]`,
{
previous: previousCandle
? debugCandleTime(previousCandle.time)
: null,
current: debugCandleTime(candle.time),
differenceSeconds: previousCandle
? candle.time - previousCandle.time
: null,
timeframeSeconds,
},
);
sessionStart = candle.time;
bucketStart = candle.time;
candleGroup = [candle];
return;
}
const currentBucketStart =
sessionStart +
Math.floor(
(candle.time - sessionStart) / timeframeSeconds,
) *
timeframeSeconds;
if (currentBucketStart !== bucketStart) {
const aggregatedCandle = aggregateCandles(
candleGroup,
bucketStart,
);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
bucketStart = currentBucketStart;
candleGroup = [];
}
candleGroup.push(candle);
});
const aggregatedCandle = aggregateCandles(
candleGroup,
bucketStart,
);
if (aggregatedCandle) {
result.push(aggregatedCandle);
}
if (syncRealtime) {
this.realtimeSessionStart = sessionStart;
this.prevRealtimeDataArr = [...candleGroup];
this.prevRealtimeData =
sortedData[sortedData.length - 1];
}
debugCandles(
`[TIME DEBUG][DataSourceProvider][convolution result][${requestedTimeframe}]`,
result,
);
return result;
}
private realtimeConvolution(
timeframe: Timeframes,
data: Candle,
update: (candle: Candle) => void,
): void {
const timeframeSeconds = getTimeframeSeconds(timeframe);
debugLog(
`[TIME DEBUG][DataSourceProvider][realtime convolution config][${timeframe}]`,
{
timeframe,
timeframeSeconds,
incoming: debugCandleTime(data.time),
previous: this.prevRealtimeData
? debugCandleTime(this.prevRealtimeData.time)
: null,
sessionStart:
this.realtimeSessionStart === null
? null
: debugCandleTime(this.realtimeSessionStart),
},
);
if (
!this.prevRealtimeData ||
this.realtimeSessionStart === null
) {
this.realtimeSessionStart = data.time;
this.prevRealtimeDataArr = [data];
} else {
const isNewSession =
data.time - this.prevRealtimeData.time >
timeframeSeconds;
if (isNewSession) {
this.realtimeSessionStart = data.time;
this.prevRealtimeDataArr = [data];
} else {
const previousBucketStart =
this.realtimeSessionStart +
Math.floor(
(this.prevRealtimeData.time -
this.realtimeSessionStart) /
timeframeSeconds,
) *
timeframeSeconds;
const currentBucketStart =
this.realtimeSessionStart +
Math.floor(
(data.time - this.realtimeSessionStart) /
timeframeSeconds,
) *
timeframeSeconds;
if (currentBucketStart !== previousBucketStart) {
this.prevRealtimeDataArr = [data];
} else {
const candleIndex =
this.prevRealtimeDataArr.findIndex(
(candle) => candle.time === data.time,
);
if (candleIndex === -1) {
this.prevRealtimeDataArr.push(data);
} else {
this.prevRealtimeDataArr[candleIndex] = data;
}
}
}
}
this.prevRealtimeData = data;
const sessionStart = this.realtimeSessionStart;
if (sessionStart === null) {
return;
}
const bucketStart =
sessionStart +
Math.floor(
(data.time - sessionStart) / timeframeSeconds,
) *
timeframeSeconds;
const candle = aggregateCandles(
this.prevRealtimeDataArr,
bucketStart,
);
if (candle) {
update(candle);
}
}
}
export { DataSourceProvider };