Загрузка данных
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { getStartTime, parseTimeframe } from 'moex-chart';
import {
getISSOddTimeframePart,
moexChartTimeConverter,
moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
import type { ManipulateType } from 'dayjs';
import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
interface ConvolutionParams {
data: Candle[];
moexCandle: number;
requestedTimeframe: Timeframes;
dayjsUnit: ManipulateType;
iterationCounter: number;
}
interface CandleGroup {
candle: Candle | null;
consumedCount: number;
}
interface RealtimeParams {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
update: (symbol: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData;
}
function normalizeSymbol(symbolRaw?: string): string {
return String(symbolRaw ?? '')
.trim()
.toUpperCase();
}
function mergeCandles(left: Candle, right: Candle): Candle {
return {
...left,
open: left.open,
high: Math.max(left.high, right.high),
low: Math.min(left.low, right.low),
close: right.close,
volume: (left.volume ?? 0) + (right.volume ?? 0),
};
}
function aggregateCandles(candles: Candle[]): Candle | null {
const firstCandle = candles[0];
if (!firstCandle) {
return null;
}
return candles
.slice(1)
.reduce(
(aggregatedCandle, currentCandle) =>
mergeCandles(aggregatedCandle, currentCandle),
firstCandle,
);
}
function getConvolutionEndTime(
candle: Candle,
requestedTimeframe: Timeframes,
moexCandle: number,
dayjsUnit: ManipulateType,
): number {
return getStartTime(
requestedTimeframe,
dayjs(candle.time)
.add(moexCandle, dayjsUnit)
.unix() * 1000,
);
}
function collectCandleGroup({
data,
startIndex,
iterationCounter,
convolutionEndTime,
}: {
data: Candle[];
startIndex: number;
iterationCounter: number;
convolutionEndTime: number;
}): CandleGroup {
let aggregatedCandle: Candle | null = null;
let consumedCount = 0;
for (
let offset = 0;
offset < iterationCounter;
offset += 1
) {
const currentCandle = data[startIndex + offset];
if (!currentCandle) {
break;
}
aggregatedCandle = aggregatedCandle
? mergeCandles(aggregatedCandle, currentCandle)
: currentCandle;
consumedCount += 1;
const isLastCandle =
startIndex + offset + 1 === data.length;
const isOutsideCurrentInterval =
currentCandle.time > convolutionEndTime * 1000;
if (isLastCandle || isOutsideCurrentInterval) {
break;
}
}
return {
candle: aggregatedCandle,
consumedCount,
};
}
function proceedConvolution({
data,
moexCandle,
requestedTimeframe,
dayjsUnit,
iterationCounter,
}: ConvolutionParams): Candle[] {
const result: Candle[] = [];
let index = 0;
while (index < data.length - 1) {
const firstCandle = data[index];
if (!firstCandle) {
break;
}
const convolutionEndTime = getConvolutionEndTime(
firstCandle,
requestedTimeframe,
moexCandle,
dayjsUnit,
);
const group = collectCandleGroup({
data,
startIndex: index,
iterationCounter,
convolutionEndTime,
});
if (!group.candle || group.consumedCount === 0) {
break;
}
result.push(group.candle);
index += group.consumedCount;
}
return result;
}
function findConvolutionStartIndex({
data,
dataStartTime,
value,
unit,
}: {
data: Candle[];
dataStartTime: number;
value: number;
unit: ManipulateType;
}): number | null {
const searchLimit = Math.min(data.length, 60);
for (let index = 0; index < searchLimit; index += 1) {
const candle = data[index];
if (!candle) {
return null;
}
const shiftedCandleTime = dayjs(candle.time)
.subtract(value, unit)
.unix();
if (shiftedCandleTime === dataStartTime) {
return index;
}
}
return null;
}
function timeframeConvolution(
data: Candle[],
requestedTimeframe: Timeframes,
): Candle[] {
const issTimeframe =
moexChartToIssTimeframe(requestedTimeframe);
if (issTimeframe === requestedTimeframe) {
return data;
}
const firstCandle = data[0];
if (!firstCandle) {
return [];
}
const {
candleWidth: moexCandle,
dayjsUnit,
} = parseTimeframe(requestedTimeframe);
const dataStartTime = getConvolutionEndTime(
firstCandle,
requestedTimeframe,
moexCandle,
dayjsUnit,
);
const { value, unit } =
getISSOddTimeframePart(issTimeframe);
const startIndex = findConvolutionStartIndex({
data,
dataStartTime,
value,
unit,
});
if (startIndex === null) {
return [];
}
const { candleWidth: issCandle } =
parseTimeframe(issTimeframe);
return proceedConvolution({
data: data.slice(startIndex),
moexCandle,
requestedTimeframe,
dayjsUnit,
iterationCounter: moexCandle / issCandle,
});
}
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private prevRealtimeDataArr: Candle[] = [];
private prevRealtimeData: Candle | undefined;
private realtimeShouldBeConvoluted = false;
private realtimeTimer:
| ReturnType<typeof setInterval>
| null = null;
public getDataSource =
(
indicativeData?: ChartIndicativeData,
cb?: (timeframe: Timeframes) => void,
) =>
async (
timeframe: Timeframes,
symbol: string,
until?: Candle,
): Promise<Candle[] | null> => {
cb?.(timeframe);
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: Date.now(),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
if (data.length === 0) {
return null;
}
const issTimeframe =
moexChartToIssTimeframe(timeframe);
this.realtimeShouldBeConvoluted =
issTimeframe !== timeframe;
return this.realtimeShouldBeConvoluted
? timeframeConvolution(data, timeframe)
: data;
};
public startRealtime({
getSymbols,
getTimeframe,
update,
periodMs = 5000,
indicativeData,
}: RealtimeParams): () => void {
this.stopRealtime();
this.realtimeTimer = setInterval(() => {
void this.updateRealtimeSymbols({
symbols: getSymbols(),
timeframe: getTimeframe(),
update,
indicativeData,
});
}, periodMs);
return () => {
this.stopRealtime();
};
}
private stopRealtime(): void {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
}
private async updateRealtimeSymbols({
symbols,
timeframe,
update,
indicativeData,
}: {
symbols: string[];
timeframe: Timeframes;
update: (symbol: string, candle: Candle) => void;
indicativeData?: ChartIndicativeData;
}): Promise<void> {
await Promise.all(
symbols.map((symbol) =>
this.updateRealtimeSymbol({
symbol,
timeframe,
update,
indicativeData,
}),
),
);
}
private async updateRealtimeSymbol({
symbol: rawSymbol,
timeframe,
update,
indicativeData,
}: {
symbol: string;
timeframe: Timeframes;
update: (symbol: string, candle: Candle) => void;
indicativeData?: ChartIndicativeData;
}): Promise<void> {
const symbol = normalizeSymbol(rawSymbol);
if (!symbol) {
return;
}
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (!data) {
return;
}
this.processRealtimeData({
symbol,
timeframe,
data,
update,
});
}
private processRealtimeData({
symbol,
timeframe,
data,
update,
}: {
symbol: string;
timeframe: Timeframes;
data: Candle;
update: (symbol: string, candle: Candle) => void;
}): void {
if (!this.prevRealtimeData) {
this.prevRealtimeData = data;
this.prevRealtimeDataArr = [data];
update(symbol, data);
return;
}
if (this.areCandlesEqual(data, this.prevRealtimeData)) {
return;
}
const candle = this.buildRealtimeCandle(
timeframe,
data,
);
update(symbol, candle);
}
private areCandlesEqual(
firstCandle: Candle,
secondCandle: Candle,
): boolean {
return (
JSON.stringify(firstCandle) ===
JSON.stringify(secondCandle)
);
}
private buildRealtimeCandle(
timeframe: Timeframes,
data: Candle,
): Candle {
if (
!this.realtimeShouldBeConvoluted ||
!this.prevRealtimeData
) {
this.prevRealtimeData = data;
this.prevRealtimeDataArr = [data];
return data;
}
if (
this.isNextTimeframeStep(
timeframe,
this.prevRealtimeData,
data,
)
) {
this.prevRealtimeData = data;
this.prevRealtimeDataArr = [data];
return data;
}
this.updateRealtimeBuffer(data);
const candle =
aggregateCandles(this.prevRealtimeDataArr) ?? data;
this.prevRealtimeData = data;
return candle;
}
private isNextTimeframeStep(
timeframe: Timeframes,
previousData: Candle,
currentData: Candle,
): boolean {
const {
candleWidth: moexCandle,
dayjsUnit,
} = parseTimeframe(timeframe);
const previousStartTime = getConvolutionEndTime(
previousData,
timeframe,
moexCandle,
dayjsUnit,
);
const currentStartTime = getConvolutionEndTime(
currentData,
timeframe,
moexCandle,
dayjsUnit,
);
return previousStartTime !== currentStartTime;
}
private updateRealtimeBuffer(data: Candle): void {
const lastBufferIndex =
this.prevRealtimeDataArr.length - 1;
const isNewBarInsideTimeframe =
this.prevRealtimeData?.time !== data.time;
if (
isNewBarInsideTimeframe ||
lastBufferIndex < 0
) {
this.prevRealtimeDataArr.push(data);
return;
}
this.prevRealtimeDataArr[lastBufferIndex] = data;
}
}
export { DataSourceProvider };