Загрузка данных
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;
}
interface RealtimeState {
prevRealtimeDataArr: Candle[];
prevRealtimeData?: Candle;
realtimeShouldBeConvoluted: boolean;
realtimeSessionStart: number | null;
}
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 realtimeTimer: ReturnType<typeof setInterval> | null = null;
private historyRequests = new Map<string, HistoryRequestState>();
private realtimeStates = new Map<string, RealtimeState>();
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;
}
const historyRequestKey = `${symbol}:${timeframe}`;
const historyRequestState = this.historyRequests.get(historyRequestKey);
if (historyRequestState && historyRequestState.untilTime === until?.time) {
if (historyRequestState.request) {
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;
}
const realtimeState = this.getRealtimeState(symbol, timeframe);
if (
realtimeState.prevRealtimeData &&
JSON.stringify(data) === JSON.stringify(realtimeState.prevRealtimeData)
) {
return;
}
if (!realtimeState.realtimeShouldBeConvoluted) {
realtimeState.prevRealtimeData = data;
update(symbol, data);
return;
}
this.realtimeConvolution(symbol, timeframe, data, (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);
const data = await requestBars({
currencyPair: symbol.replaceAll(':', '.'),
interval,
periodParams: {
firstDataRequest: true,
to: date,
from: Math.round(Date.now() / 1000),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
if (data.length === 0) {
return null;
}
const issTimeframe = moexChartToIssTimeframe(timeframe);
const realtimeState = this.getRealtimeState(symbol, timeframe);
if (issTimeframe === timeframe) {
realtimeState.realtimeShouldBeConvoluted = false;
if (!until) {
realtimeState.prevRealtimeData = data[data.length - 1];
realtimeState.prevRealtimeDataArr = [];
realtimeState.realtimeSessionStart = null;
}
return data;
}
realtimeState.realtimeShouldBeConvoluted = true;
return this.timeframeConvolution(data, timeframe, symbol, !until);
}
private timeframeConvolution(
data: Candle[],
requestedTimeframe: Timeframes,
symbol: string,
syncRealtime: boolean,
): Candle[] {
const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
const sortedData = [...data].sort((first, second) => first.time - second.time);
const firstCandle = sortedData[0];
if (!firstCandle) {
return [];
}
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);
}
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) {
const realtimeState = this.getRealtimeState(symbol, requestedTimeframe);
realtimeState.realtimeSessionStart = sessionStart;
realtimeState.prevRealtimeDataArr = candleGroup.slice();
realtimeState.prevRealtimeData = sortedData[sortedData.length - 1];
}
return result;
}
private realtimeConvolution(
symbol: string,
timeframe: Timeframes,
data: Candle,
update: (candle: Candle) => void,
): void {
const timeframeSeconds = getTimeframeSeconds(timeframe);
const realtimeState = this.getRealtimeState(symbol, timeframe);
if (!realtimeState.prevRealtimeData || realtimeState.realtimeSessionStart === null) {
realtimeState.realtimeSessionStart = data.time;
realtimeState.prevRealtimeDataArr = [data];
} else {
const isNewSession = data.time - realtimeState.prevRealtimeData.time > timeframeSeconds;
if (isNewSession) {
realtimeState.realtimeSessionStart = data.time;
realtimeState.prevRealtimeDataArr = [data];
} else {
const previousBucketStart =
realtimeState.realtimeSessionStart +
Math.floor(
(realtimeState.prevRealtimeData.time - realtimeState.realtimeSessionStart) / timeframeSeconds,
) *
timeframeSeconds;
const currentBucketStart =
realtimeState.realtimeSessionStart +
Math.floor((data.time - realtimeState.realtimeSessionStart) / timeframeSeconds) * timeframeSeconds;
if (currentBucketStart !== previousBucketStart) {
realtimeState.prevRealtimeDataArr = [data];
} else {
const candleIndex = realtimeState.prevRealtimeDataArr.findIndex((candle) => candle.time === data.time);
if (candleIndex === -1) {
realtimeState.prevRealtimeDataArr.push(data);
} else {
realtimeState.prevRealtimeDataArr[candleIndex] = data;
}
}
}
}
realtimeState.prevRealtimeData = data;
const sessionStart = realtimeState.realtimeSessionStart;
if (sessionStart === null) {
return;
}
const bucketStart = sessionStart + Math.floor((data.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
const candle = aggregateCandles(realtimeState.prevRealtimeDataArr, bucketStart);
if (candle) {
update(candle);
}
}
private getRealtimeState(symbol: string, timeframe: Timeframes): RealtimeState {
const key = `${symbol}:${timeframe}`;
let realtimeState = this.realtimeStates.get(key);
if (!realtimeState) {
realtimeState = {
prevRealtimeDataArr: [],
realtimeShouldBeConvoluted: moexChartToIssTimeframe(timeframe) !== timeframe,
realtimeSessionStart: null,
};
this.realtimeStates.set(key, realtimeState);
}
return realtimeState;
}
}
export { DataSourceProvider };
export interface IndicatorDataFormatter<T extends SeriesType> {
mainSeriesData: SerieData[];
selfData: ChartTypeToCandleData[T][];
inputData?: Candle[];
candle?: SerieData;
indicatorReference?: Indicator;
settings?: SettingsValues;
}
protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
if (!this.customFormatter) {
return this.formatMainSerie(inputData);
}
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
const selfData = this.data() as unknown as ChartTypeToCandleData[TSeries][];
if (inputData.length !== 1) {
return this.customFormatter({
mainSeriesData,
selfData,
inputData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
const candle = this.formatMainSerie(inputData)[0];
if (!candle) {
return [];
}
return this.customFormatter({
mainSeriesData,
selfData,
inputData,
candle: candle as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
import { SeriesDataItemTypeMap, Time } from 'lightweight-charts';
import type { IndicatorDataFormatter } from '@core/Indicators';
export function compareIndicator({
mainSeriesData,
inputData = [],
}: IndicatorDataFormatter<'Line'>): SeriesDataItemTypeMap<Time>['Line'][] {
const mainTimes = mainSeriesData.reduce<number[]>((result, item) => {
if (typeof item.time === 'number') {
result.push(item.time);
}
return result;
}, []);
if (mainTimes.length < 2 || inputData.length === 0) {
return [];
}
let timeframeSeconds = Number.POSITIVE_INFINITY;
for (let index = 1; index < mainTimes.length; index += 1) {
const diff = mainTimes[index] - mainTimes[index - 1];
if (diff > 0 && diff < timeframeSeconds) {
timeframeSeconds = diff;
}
}
if (!Number.isFinite(timeframeSeconds)) {
return [];
}
const result: SeriesDataItemTypeMap<Time>['Line'][] = [];
let mainIndex = 0;
for (let index = 0; index < inputData.length; index += 1) {
const candle = inputData[index];
while (mainIndex + 1 < mainTimes.length && mainTimes[mainIndex + 1] <= candle.time) {
mainIndex += 1;
}
let time = mainTimes[mainIndex];
if (candle.time < time) {
continue;
}
if (mainIndex === mainTimes.length - 1 && candle.time >= time + timeframeSeconds) {
time += timeframeSeconds;
}
if (candle.time < time || candle.time >= time + timeframeSeconds) {
continue;
}
const point: SeriesDataItemTypeMap<Time>['Line'] = {
time: time as Time,
value: candle.close,
customValues: candle as unknown as Record<string, unknown>,
};
const lastPoint = result[result.length - 1];
if (lastPoint?.time === point.time) {
result[result.length - 1] = point;
} else {
result.push(point);
}
}
return result;
}
function getDefaultCompareIndicatorConfig(
seriesType: SeriesType,
symbolInfo: SymbolInfo,
usedColors: string[],
): IndicatorConfig {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
symbolInfo,
newPane: true,
label: symbolInfo.symbolName,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
dataFormatter: (params) => compareIndicator(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
}