Загрузка данных
import React, { FC, lazy, useContext, useState } from 'react';
import { DNDWrapper } from '@components/DNDWrapper';
import { InstrumentSearch } from '@components/InstrumentSearch';
import WidgetContentWrapper from '@components/WidgetContentWrapper';
import WidgetHeader from '@components/WidgetHeader';
import { useDropInstrument } from '@hooks/dnd';
import { WidgetEnvContext } from '@terminal/desktop/workspaces/default/components/Widget/context';
import { useWidgetHeaderName } from '@utils/useWidgetName';
import useChartComponentFacade from './hooks/useChartComponentFacade';
import type { ChartContainerProps } from './types';
const MoexChart = lazy(() => import('./components/MoexChart/MoexChart'));
export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
const indicativeData = props.widgetContentProps?.indicativeData;
const {
dropDownOpen,
setDropdownOpen,
currentSymbolInfo,
setIsWidgetHeaderContextMenuOpen,
isWidgetHeaderContextMenuOpen,
onDropInstrument,
addInstrumentFromModal,
isOver,
} = useChartComponentFacade(props);
/* на дроп обновляем название бумаги в сторе
и делаем апдейт на бэк, чтобы сохранить изменения
при обновлении страницы */
const { dropRef } = useDropInstrument((dragData) => {
onDropInstrument(dragData.properties.issKey, true);
});
const [isOpenEmptyAction, setIsOpenEmptyAction] = useState<boolean>(false);
const openInstrumentModal = () => {
setIsOpenEmptyAction(true);
setIsWidgetHeaderContextMenuOpen(false);
};
// TODO: Зависит от сеток
const { isDraggedOver } = useContext(WidgetEnvContext);
const contractsInstrumentName = useWidgetHeaderName(currentSymbolInfo.symbolId);
const symbolInfo = indicativeData
? {
symbolId: indicativeData.key,
symbol: indicativeData.secId,
symbolName: `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`,
}
: currentSymbolInfo;
const instrumentName = symbolInfo.symbolName || contractsInstrumentName || symbolInfo.symbol || symbolInfo.symbolId;
return (
<DNDWrapper
ref={dropRef}
isOver={isOver}
canDrop
>
<WidgetHeader
{...props}
dropdownOpen={dropDownOpen}
setDropdownOpen={setDropdownOpen}
itemsSearchIcon={[true]}
handlerSaveAsExcel={null}
addToWidgetNamePrefix={instrumentName}
setIsOpenContextMenuFromWidget={setIsWidgetHeaderContextMenuOpen}
isOpenContextMenuFromWidget={isWidgetHeaderContextMenuOpen}
openInstrumentsModal={openInstrumentModal}
/>
<WidgetContentWrapper {...props}>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
pointerEvents: isDraggedOver ? 'none' : 'inherit',
}}
>
<MoexChart
symbolInfo={symbolInfo}
indicativeData={indicativeData}
widgetId={props.widgetId}
addInstrumentFromModal={addInstrumentFromModal}
/>
{isOpenEmptyAction && (
<InstrumentSearch
setOpen={setIsOpenEmptyAction}
isOpen={isOpenEmptyAction}
variant="single"
widgetId={props.widgetId}
// withNRD={false}
addInstruments={addInstrumentFromModal}
/>
)}
</div>
</WidgetContentWrapper>
</DNDWrapper>
);
};
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);
function normalizeSymbol(symbolRaw?: string): string {
return String(symbolRaw ?? '').trim();
}
const collectCandleGroup = ({
data,
startIndex,
iterationCounter,
startTime,
}: {
data: Candle[];
startIndex: number;
iterationCounter: number;
startTime: number;
}): {
aggregatedCandle: Candle | undefined;
consumedCount: number;
} => {
let aggregatedCandle: Candle | undefined;
let offset = 0;
let consumedCount = 0;
while (offset < iterationCounter) {
const currentCandle = data[startIndex + offset];
if (!currentCandle) {
break;
}
if (!aggregatedCandle) {
aggregatedCandle = currentCandle;
} else {
aggregatedCandle = {
...aggregatedCandle,
high: Math.max(currentCandle.high, aggregatedCandle.high),
low: Math.min(currentCandle.low, aggregatedCandle.low),
close: currentCandle.close,
volume: (aggregatedCandle.volume ?? 0) + (currentCandle.volume ?? 0),
};
}
consumedCount += 1;
if (startIndex + offset + 1 === data.length || currentCandle.time > startTime * 1000) {
break;
}
offset += 1;
}
return {
aggregatedCandle,
consumedCount,
};
};
const proceedConvolution = ({
data,
moexCandle,
requestedTimeframe,
dayjsUnit,
iterationCounter,
}: {
data: Candle[];
moexCandle: number;
requestedTimeframe: Timeframes;
dayjsUnit: ManipulateType;
iterationCounter: number;
}): Candle[] => {
const result: Candle[] = [];
let index = 0;
while (index < data.length - 1) {
const firstCandle = data[index];
if (!firstCandle) {
break;
}
const startTime = getStartTime(
requestedTimeframe,
dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
);
const { aggregatedCandle, consumedCount } = collectCandleGroup({
data,
startIndex: index,
iterationCounter,
startTime,
});
if (!aggregatedCandle || consumedCount === 0) {
break;
}
result.push(aggregatedCandle);
index += consumedCount;
}
return result;
};
const timeframeConvolution = (data: Candle[], requestedTimeframe: Timeframes): Candle[] => {
const issTimeframe = moexChartToIssTimeframe(requestedTimeframe);
if (issTimeframe === requestedTimeframe) {
return data;
}
const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(requestedTimeframe);
const firstCandle = data[0];
if (!firstCandle) {
return [];
}
const dataStartTime = getStartTime(
requestedTimeframe,
dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
);
const { value, unit } = getISSOddTimeframePart(issTimeframe);
let startIndex = 0;
while (
startIndex < data.length &&
startIndex < 60 &&
dataStartTime !== dayjs(data[startIndex].time).subtract(value, unit).unix()
) {
startIndex += 1;
}
if (startIndex >= data.length || startIndex >= 60) {
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) => {
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);
if (issTimeframe === timeframe) {
this.realtimeShouldBeConvoluted = false;
return data;
}
this.realtimeShouldBeConvoluted = true;
return timeframeConvolution(data, timeframe);
};
public startRealtime({
getSymbols,
getTimeframe,
update,
periodMs = 5000,
indicativeData,
}: {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
update: (symbol: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData;
}): () => void {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = setInterval(() => {
const timeframe = getTimeframe();
const symbols = getSymbols();
Promise.all(
symbols.map(async (value) => {
const symbol = normalizeSymbol(value);
if (!symbol) {
return;
}
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (!data) {
return;
}
if (!this.prevRealtimeData) {
this.prevRealtimeData = data;
this.prevRealtimeDataArr = [data];
update(symbol, data);
return;
}
if (JSON.stringify(data) !== JSON.stringify(this.prevRealtimeData)) {
this.realtimeConvolution(timeframe, data, (candle) => {
update(symbol, candle);
});
}
}),
);
}, periodMs);
return () => {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
};
}
private realtimeConvolution(timeframe: Timeframes, data: Candle, update: (candle: Candle) => void): void {
let candle = data;
if (this.realtimeShouldBeConvoluted && this.prevRealtimeData) {
const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(timeframe);
const dataStartTimeFromPrevData = getStartTime(
timeframe,
dayjs(this.prevRealtimeData.time).add(moexCandle, dayjsUnit).unix() * 1000,
);
const dataStartTime = getStartTime(timeframe, dayjs(data.time).add(moexCandle, dayjsUnit).unix() * 1000);
const isNextTimeframeStep = dataStartTime !== dataStartTimeFromPrevData;
if (isNextTimeframeStep) {
this.prevRealtimeDataArr = [data];
} else {
const isNewBarInsideTimeframe = this.prevRealtimeData.time !== data.time;
if (isNewBarInsideTimeframe) {
this.prevRealtimeDataArr.push(data);
} else {
this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1] = data;
}
const firstCandle = this.prevRealtimeDataArr[0];
const lastCandle = this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1];
if (firstCandle && lastCandle) {
candle = {
time: firstCandle.time,
open: firstCandle.open,
high: Math.max(...this.prevRealtimeDataArr.map(({ high }) => high)),
low: Math.min(...this.prevRealtimeDataArr.map(({ low }) => low)),
close: lastCandle.close,
volume: this.prevRealtimeDataArr.reduce((total, currentCandle) => total + (currentCandle.volume ?? 0), 0),
};
}
}
}
this.prevRealtimeData = data;
update(candle);
}
}
export { DataSourceProvider };