Загрузка данных
import { parseTimeframe } from '@mx-charts/charts';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { moexChartTimeConverter, moexChartToIssTimeframe } from '@utils/chartToReqTimeConverter';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
import type { Candle, Timeframes } from '@mx-charts/charts';
dayjs.extend(duration);
interface RealtimeState {
previousTime: number | null;
candles: Candle[];
sessionStart: number | null;
}
function getRequestSymbol(symbolRaw?: string): string | undefined {
const symbol = String(symbolRaw ?? '').trim();
if (!symbol) {
return undefined;
}
return symbol;
}
function getTimeframeSeconds(timeframe: Timeframes): number {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}
function isValidCandle({ time, open, high, low, close, volume }: Candle): boolean {
const valuesAreValid = [time, open, high, low, close].every(Number.isFinite);
return (
valuesAreValid &&
(volume === undefined || Number.isFinite(volume)) &&
high >= Math.max(open, close) &&
low <= Math.min(open, close)
);
}
function isSameCandle(first: Candle | undefined, second: Candle): boolean {
return (
first?.time === second.time &&
first.open === second.open &&
first.high === second.high &&
first.low === second.low &&
first.close === second.close &&
first.volume === second.volume
);
}
function aggregateCandles(candles: Candle[], time: number): Candle | undefined {
const firstCandle = candles[0];
const lastCandle = candles[candles.length - 1];
if (!firstCandle || !lastCandle) {
return undefined;
}
const high = Math.max(...candles.map((candle) => candle.high));
const low = Math.min(...candles.map((candle) => candle.low));
const volume = candles.reduce((total, candle) => total + (candle.volume ?? 0), 0);
return {
time,
open: firstCandle.open,
high,
low,
close: lastCandle.close,
volume,
};
}
function appendAggregatedCandle(result: Candle[], candles: Candle[], time: number): void {
const candle = aggregateCandles(candles, time);
if (candle) {
result.push(candle);
}
}
function timeframeConvolution(
data: Candle[],
requestedTimeframe: Timeframes,
): {
candles: Candle[];
state: RealtimeState;
} {
const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
const sortedData = [...data].sort((first, second) => first.time - second.time);
const firstCandle = sortedData[0];
if (!firstCandle) {
return {
candles: [],
state: {
previousTime: null,
candles: [],
sessionStart: null,
},
};
}
const result: Candle[] = [];
let sessionStart = firstCandle.time;
let bucketStart = sessionStart;
let previousTime: number | null = null;
let candleGroup: Candle[] = [];
sortedData.forEach((candle) => {
const isNewSession = previousTime !== null && candle.time - previousTime > timeframeSeconds;
if (isNewSession) {
appendAggregatedCandle(result, candleGroup, bucketStart);
sessionStart = candle.time;
bucketStart = candle.time;
candleGroup = [candle];
previousTime = candle.time;
return;
}
const currentBucketStart =
sessionStart + Math.floor((candle.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
if (currentBucketStart !== bucketStart) {
appendAggregatedCandle(result, candleGroup, bucketStart);
bucketStart = currentBucketStart;
candleGroup = [];
}
candleGroup.push(candle);
previousTime = candle.time;
});
appendAggregatedCandle(result, candleGroup, bucketStart);
return {
candles: result,
state: {
previousTime,
candles: candleGroup,
sessionStart,
},
};
}
function realtimeConvolution(
timeframe: Timeframes,
data: Candle,
currentState: RealtimeState,
): {
candle: Candle | undefined;
state: RealtimeState;
} {
const timeframeSeconds = getTimeframeSeconds(timeframe);
const { previousTime, sessionStart } = currentState;
if (previousTime === null || sessionStart === null || data.time - previousTime > timeframeSeconds) {
const state: RealtimeState = {
previousTime: data.time,
candles: [data],
sessionStart: data.time,
};
return {
candle: aggregateCandles(state.candles, data.time),
state,
};
}
const previousBucketStart =
sessionStart + Math.floor((previousTime - sessionStart) / timeframeSeconds) * timeframeSeconds;
const currentBucketStart =
sessionStart + Math.floor((data.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
let candles: Candle[];
if (currentBucketStart !== previousBucketStart) {
candles = [data];
} else {
const candleIndex = currentState.candles.findIndex((candle) => candle.time === data.time);
if (candleIndex === -1) {
candles = [...currentState.candles, data];
} else {
candles = [...currentState.candles];
candles[candleIndex] = data;
}
}
const state: RealtimeState = {
previousTime: data.time,
candles,
sessionStart,
};
return {
candle: aggregateCandles(candles, currentBucketStart),
state,
};
}
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private readonly realtimeStates = new Map<string, RealtimeState>();
private realtimeTimer: ReturnType<typeof setInterval> | null = null;
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;
}
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: Math.round(Date.now() / 1000),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
const validData = data.filter(isValidCandle);
if (validData.length === 0) {
return null;
}
if (moexChartToIssTimeframe(timeframe) === timeframe) {
if (!until) {
const lastCandle = validData.at(-1);
this.realtimeStates.set(symbol, {
previousTime: lastCandle?.time ?? null,
candles: lastCandle ? [lastCandle] : [],
sessionStart: null,
});
}
return validData;
}
const { candles, state } = timeframeConvolution(validData, timeframe);
if (!until) {
this.realtimeStates.set(symbol, state);
}
return candles;
};
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();
Promise.allSettled(
getSymbols().map((symbolId) => this.updateRealtimeSymbol(symbolId, timeframe, update, indicativeData)),
);
}, periodMs);
return () => {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
};
}
private async updateRealtimeSymbol(
symbolId: string,
timeframe: Timeframes,
update: (symbolId: string, candle: Candle) => void,
indicativeData?: ChartIndicativeData,
): Promise<void> {
const symbol = getRequestSymbol(symbolId);
if (!symbol) {
return;
}
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (!data || !isValidCandle(data)) {
return;
}
const currentState = this.realtimeStates.get(symbol) ?? {
previousTime: null,
candles: [],
sessionStart: null,
};
const currentCandle = currentState.candles.find((candle) => candle.time === data.time);
if (isSameCandle(currentCandle, data)) {
return;
}
if (moexChartToIssTimeframe(timeframe) === timeframe) {
this.realtimeStates.set(symbol, {
previousTime: data.time,
candles: [data],
sessionStart: null,
});
update(symbol, data);
return;
}
const { candle, state } = realtimeConvolution(timeframe, data, currentState);
this.realtimeStates.set(symbol, state);
if (candle) {
update(symbol, candle);
}
}
}
export { DataSourceProvider };
import React from 'react';
import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
import type { ChartIndicativeData, SelectedInstrument } from '../../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
import '@mx-charts/charts/dist/styles.css';
interface TRProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
widgetId: number;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
}
function MoexChart({ symbolInfo, indicativeData, widgetId, addInstrumentFromModal }: TRProps) {
const {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
} = useMoexChart({
symbolInfo,
indicativeData,
});
const handleSymbolChange = (instrument: SelectedInstrument): void => {
addInstrumentFromModal([instrument]);
};
return (
<div
style={{
flex: '1 1 0',
minHeight: 0,
minWidth: 0,
}}
>
<div ref={containerRef} />
{isCompareOpen && (
<CompareModal
compareManager={compareManagerRef}
widgetId={widgetId}
isOpen={isCompareOpen}
setOpen={setIsCompareOpen}
/>
)}
{isSymbolSearchOpen && (
<SymbolSearchModal
widgetId={widgetId}
isOpen={isSymbolSearchOpen}
setOpen={setIsSymbolSearchOpen}
onSymbolChange={handleSymbolChange}
/>
)}
</div>
);
}
export default React.memo(MoexChart);
import { Intervals, MoexChart, Timeframes } from '@mx-charts/charts';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';
import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
import type { __CompareManager__, ChartSeriesType, IMoexChart, SymbolInfoInput } from '@mx-charts/charts';
interface TUseMoexChartProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
}
interface SnapshotSettings {
timeframe: Timeframes;
interval?: Intervals;
chartSeriesType?: ChartSeriesType;
}
export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties(
(widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
);
const { updateProperties } = useChangeProperties<WidgetProperties>();
const [isCompareOpen, setIsCompareOpen] = useState(false);
const [isSymbolSearchOpen, setIsSymbolSearchOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<MoexChart | null>(null);
const compareManagerRef = useRef<__CompareManager__ | null>(null);
const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
const initialChartSeriesTypeRef = useRef<ChartSeriesType | undefined>(moexChartState?.initialChartSeriesType);
const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);
useEffect(() => {
const currentSymbolInfo = currentSymbolInfoRef.current;
if (
currentSymbolInfo.symbolId === symbolInfo.symbolId &&
currentSymbolInfo.symbol === symbolInfo.symbol &&
currentSymbolInfo.symbolName === symbolInfo.symbolName
) {
return;
}
currentSymbolInfoRef.current = symbolInfo;
chartRef.current?.setSymbol(symbolInfo);
}, [symbolInfo]);
useEffect(() => {
timeframeRef.current = moexChartState?.timeframe;
savedDataRef.current = moexChartState?.savedData;
}, [moexChartState?.savedData, moexChartState?.timeframe]);
updateTimeframeRef.current = (timeframe: Timeframes): void => {
if (timeframeRef.current === timeframe) {
return;
}
timeframeRef.current = timeframe;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe,
};
});
};
const saveSnapshot = useCallback((): void => {
const snapshot = chartRef.current?.getSnapshot();
if (!snapshot) {
return;
}
const savedData = JSON.stringify(snapshot);
if (savedDataRef.current === savedData) {
return;
}
savedDataRef.current = savedData;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe: timeframeRef.current ?? Timeframes['1m'],
savedData,
};
});
}, [updateProperties]);
const getSnapshotWithCurrentSettings = (
snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
{ timeframe, interval, chartSeriesType }: SnapshotSettings,
): IMoexChart['snapshot'] => ({
...snapshot,
charts: snapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
...currentSymbolInfoRef.current,
timeframe,
...(interval ? { interval } : {}),
...(chartSeriesType ? { chartSeriesType } : {}),
})),
});
const applySnapshot = (): void => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current ?? Timeframes['1m'];
chartRef.current.setSnapshot(getSnapshotWithCurrentSettings(savedSnapshot, { timeframe }));
compareManagerRef.current = chartRef.current.getCompareManager();
};
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const timeframe = timeframeRef.current ?? Timeframes['1m'];
const initialInterval = initialIntervalRef.current;
const initialChartSeriesType = initialChartSeriesTypeRef.current;
const savedSnapshot = savedDataRef.current
? (JSON.parse(savedDataRef.current) as IMoexChart['snapshot'])
: MOEX_CHART_CONFIG.snapshot;
const dataProvider = new DataSourceProvider();
const chart = new MoexChart({
...MOEX_CHART_CONFIG,
container,
snapshot: getSnapshotWithCurrentSettings(savedSnapshot, {
timeframe,
interval: initialInterval,
chartSeriesType: initialChartSeriesType,
}),
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
updateTimeframeRef.current?.(nextTimeframe);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
getSymbols,
getTimeframe,
update,
indicativeData,
}),
},
});
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
if (initialInterval || initialChartSeriesType) {
initialIntervalRef.current = undefined;
initialChartSeriesTypeRef.current = undefined;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
initialInterval: undefined,
initialChartSeriesType: undefined,
};
});
}
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
return () => {
clearInterval(intervalId);
saveSnapshot();
chartRef.current = null;
compareManagerRef.current = null;
chart.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- график пересоздаётся только при изменении источника индикативных данных
}, [indicativeData?.key]);
return {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
};
};
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { communicator } from '@core/comm';
import { useAppSelect } from '@hooks/useAppSelector';
import { CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT, HIGHLIGHT_WIDGET_EVENT } from '@modules/widgets/shared';
import { addContentPropsToWidget, unbindWidgets } from '@store/slices/widgets';
import { getStore } from '@store/store';
import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps, SelectedInstrument } from '../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
interface UseChartComponentFacadeReturn {
dropDownOpen: boolean;
setDropdownOpen: Dispatch<SetStateAction<boolean>>;
currentSymbolInfo: SymbolInfoInput;
isWidgetHeaderContextMenuOpen: boolean;
setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
onDropInstrument: (symbolId: string, withUpdate?: boolean) => void;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
isOver: boolean;
}
type SaveContentPropsOptions = Partial<WidgetProperties['chartState']> & {
withUpdate?: boolean;
cleanIndicativeData?: boolean;
};
function useInstrumentChange({
saveContentProps,
setIsWidgetHeaderContextMenuOpen,
currentSymbolInfoRef,
setCurrentSymbolInfo,
triggerRelatedWidgetsToUpdateRef,
widgetId
}: {
saveContentProps: (value: SaveContentPropsOptions) => void,
setIsWidgetHeaderContextMenuOpen: (value: boolean) => void,
currentSymbolInfoRef: MutableRefObject<SymbolInfoInput>,
setCurrentSymbolInfo: (value: SymbolInfoInput) => void,
triggerRelatedWidgetsToUpdateRef: MutableRefObject<(key: string) => void>,
widgetId: number
}) {
const dispatch = useDispatch();
const currentSymbolInfoRefCrunch = currentSymbolInfoRef
const onInstrumentChange = useCallback(
(symbolInfo: SymbolInfoInput, withUpdate?: boolean, unbind = true): void => {
const symbolId = symbolInfo.symbolId.trim();
if (!symbolId) {
return;
}
setIsWidgetHeaderContextMenuOpen(false);
const currentValue = currentSymbolInfoRefCrunch.current;
const symbolIdChanged = currentValue.symbolId !== symbolId;
const symbol = symbolInfo.symbol?.trim() || (symbolIdChanged ? undefined : currentValue.symbol);
const symbolName = symbolInfo.symbolName?.trim() || (symbolIdChanged ? undefined : currentValue.symbolName);
const nextSymbolInfo: SymbolInfoInput = {
symbolId,
symbol,
symbolName,
};
const symbolChanged = currentValue.symbol !== symbol;
const symbolNameChanged = currentValue.symbolName !== symbolName;
if (!symbolIdChanged && !symbolChanged && !symbolNameChanged) {
return;
}
currentSymbolInfoRefCrunch.current = nextSymbolInfo;
setCurrentSymbolInfo(nextSymbolInfo);
if (symbolIdChanged && unbind) {
dispatch(unbindWidgets({ widgetId }));
}
// при смене инструмента очищаем индикативные данные виджета график
// т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
saveContentProps({
savedInstrument: symbolId,
savedSymbol: symbol,
savedSymbolName: symbolName,
withUpdate,
cleanIndicativeData: symbolIdChanged,
});
if (symbolIdChanged) {
triggerRelatedWidgetsToUpdateRef.current(symbolId);
}
},
[dispatch, saveContentProps, widgetId, currentSymbolInfoRefCrunch, setCurrentSymbolInfo, setIsWidgetHeaderContextMenuOpen, triggerRelatedWidgetsToUpdateRef ],
);
const addInstrumentFromModal = useCallback(
(instruments: SelectedInstrument[]): void => {
const instrument = instruments[0];
if (!instrument?.issKey) {
return;
}
onInstrumentChange({
symbolId: instrument.issKey,
symbol: instrument.symbol,
symbolName: instrument.displayName,
});
},
[onInstrumentChange],
);
const onInstrumentChangeFromBind = useCallback(
(symbolInfo: SymbolInfoInput): void => {
onInstrumentChange(symbolInfo, true, false);
},
[onInstrumentChange],
);
const onDropInstrument = useCallback(
(symbolId: string, withUpdate?: boolean): void => {
onInstrumentChange({ symbolId }, withUpdate);
},
[onInstrumentChange],
);
return {addInstrumentFromModal, onInstrumentChangeFromBind, onDropInstrument, onInstrumentChange}
}
export default function useChartComponentFacade(props: ChartContainerProps): UseChartComponentFacadeReturn {
const { widgetId } = props;
const dispatch = useDispatch();
const [isOver, setIsOver] = useState(false);
const [dropDownOpen, setDropdownOpen] = useState(false);
const [isWidgetHeaderContextMenuOpen, setIsWidgetHeaderContextMenuOpen] = useState(false);
const widgetProperties = useAppSelect(
(state) => state.widgets.widgets.find(({ id }) => id === widgetId)?.widgetContentProps,
) as WidgetProperties | undefined;
// TODO временно реф из-за непредсказуемого изменения если используется useState,
// нужен рефакторинг и вернуть обратно useState
const currentSymbolInfoRef = useRef<SymbolInfoInput>(DEFAULT_SYMBOL)
const savedInstrument = widgetProperties?.chartState?.savedInstrument
if(savedInstrument){
currentSymbolInfoRef.current = {
symbolId: savedInstrument,
symbol: widgetProperties?.chartState?.savedSymbol,
symbolName: widgetProperties?.chartState?.savedSymbolName,
};
}
const [currentSymbolInfo, setCurrentSymbolInfo] = useState<SymbolInfoInput>(currentSymbolInfoRef.current);
const { triggerRelatedWidgetsToUpdate, getMasterInstrumentFromPublicContext } = useWidgetsBind({
widgetId,
});
const triggerRelatedWidgetsToUpdateRef = useRef(triggerRelatedWidgetsToUpdate);
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current = triggerRelatedWidgetsToUpdate;
}, [triggerRelatedWidgetsToUpdate]);
const saveContentProps = useCallback(
(value: SaveContentPropsOptions): void => {
const { withUpdate, cleanIndicativeData, ...chartStateValue } = value;
const oldProps = (getStore().getState().widgets.widgets.find(({ id }) => id === widgetId) as Widget | undefined)
?.widgetContentProps;
if (!oldProps) {
return;
}
dispatch(
addContentPropsToWidget({
id: widgetId,
withoutSend: !withUpdate,
widgetContentProps: {
chartState: {
...oldProps.chartState,
...chartStateValue,
},
// при смене инструмента очищаем индикативные данные, переданные из виджета Индикативные котировки
indicativeData: cleanIndicativeData ? undefined : oldProps.indicativeData,
},
}),
);
},
[dispatch, widgetId],
);
const { addInstrumentFromModal, onInstrumentChangeFromBind, onDropInstrument, onInstrumentChange } = useInstrumentChange({
saveContentProps,
setIsWidgetHeaderContextMenuOpen,
currentSymbolInfoRef,
setCurrentSymbolInfo,
triggerRelatedWidgetsToUpdateRef,
widgetId
})
useChartPublicContext({
setCurrInstrument: onInstrumentChangeFromBind,
widgetId,
getMasterInstrumentFromPublicContext,
});
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current(currentSymbolInfoRef.current.symbolId);
}, []);
useEffect(() => {
const unsubscribe = communicator.listen(
{
messageType: CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
},
(message) => {
const symbolId = (message as Record<number, string>)[widgetId];
if (symbolId) {
onInstrumentChange({ symbolId });
}
},
);
const unsubscribeHighlighter = communicator.listen(
{
messageType: HIGHLIGHT_WIDGET_EVENT,
},
(message) => {
const typedMessage = message as Record<number, boolean>;
if (Object.keys(typedMessage).includes(String(widgetId))) {
setIsOver(typedMessage[widgetId]);
}
},
);
return () => {
unsubscribe();
unsubscribeHighlighter();
};
}, [onInstrumentChange, widgetId]);
return {
dropDownOpen,
setDropdownOpen,
currentSymbolInfo,
isWidgetHeaderContextMenuOpen,
setIsWidgetHeaderContextMenuOpen,
onDropInstrument,
addInstrumentFromModal,
isOver,
};
}
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';
import { ChartIndicativeData } from './types';
import { isIndicativeTicker } from './utils/isIndicativeTicker';
import { transformKeyToLowerCase } from './utils/transformKeyToLowerCase';
import type { Candle } from '@mx-charts/charts';
dayjs.extend(utc);
export interface PeriodParams {
from: number;
to: number;
countBack: number;
firstDataRequest: boolean;
}
export interface HistoryMetadata {
noData: boolean;
}
export type HistoryCallback = (candles: Candle[], metadata: HistoryMetadata) => void;
export type SubscribeBarsCallback = (candle: Candle) => void;
interface RequestBarsArgs {
currencyPair: string;
interval: string;
periodParams: PeriodParams;
onHistoryCallback?: HistoryCallback;
ticker?: string;
indicativeData?: ChartIndicativeData;
}
interface RequestRealtimeBarsArgs {
currencyPair: string;
interval: string;
ticker?: string;
indicativeData?: ChartIndicativeData;
onRealtimeCallback?: SubscribeBarsCallback;
}
export type CustomBarsResolver = (args: {
ticker?: string;
currencyPair: string;
periodParams: PeriodParams;
interval: string;
}) => Promise<Candle[]>;
const barsResolvers = new Map<string, CustomBarsResolver>();
export const registerBarsResolver = (boardKey: string, resolver: CustomBarsResolver) => {
barsResolvers.set(boardKey, resolver);
};
const getResolverKey = (ticker?: string) => {
const [source, board] = ticker?.split(/[:.]/) ?? [];
if (!source || !board) {
return '';
}
return `${source}:${board}`;
};
const getBarsResolver = (ticker?: string) => barsResolvers.get(getResolverKey(ticker));
export async function requestBars({
currencyPair,
interval,
periodParams,
onHistoryCallback,
ticker,
indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
const customResolver = getBarsResolver(ticker);
if (customResolver) {
try {
const customBars = await customResolver({ ticker, currencyPair, periodParams, interval });
const toMs = periodParams.to * 1000;
const olderBars = customBars.filter((bar) => bar.time < toMs);
onHistoryCallback?.(olderBars, { noData: olderBars.length === 0 });
return olderBars;
} catch (e) {
onHistoryCallback?.([], { noData: true });
return [];
}
}
const date = new Date(periodParams.to * 1000);
const year = date.getUTCFullYear();
const month = `0${date.getUTCMonth() + 1}`.slice(-2);
const day = `0${date.getUTCDate()}`.slice(-2);
const hours = `0${date.getUTCHours()}`.slice(-2);
const minutes = `0${date.getUTCMinutes()}`.slice(-2);
const seconds = `0${date.getUTCSeconds()}`.slice(-2);
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий инструмент совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
const lowerCaseKey = transformKeyToLowerCase(currencyPair);
if (!lowerCaseKey) {
onHistoryCallback?.([], { noData: true });
return [];
}
try {
const { data } = await indicativeQuotesController.getCandles({
count: periodParams.countBack,
key: lowerCaseKey,
date: dateStr,
interval,
});
const candles = data.indicativeCandles.reverse().map(candleToBar);
onHistoryCallback?.(candles, {
noData: candles.length === 0,
});
return candles;
} catch {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
}
const dateStr = `${year}-${month}-${day}%20${hours}:${minutes}:${seconds}`;
try {
const { data } = await api.getBars({
currencyPair,
date: dateStr,
interval,
count: periodParams.countBack,
ticker,
});
const bars = data.reverse().map(candleToBar);
onHistoryCallback?.(bars, {
noData: bars.length === 0,
});
return bars;
} catch {
onHistoryCallback?.([], {
noData: true,
});
return [];
}
}
export async function requestRealtimeBars({
currencyPair,
interval,
ticker,
onRealtimeCallback,
indicativeData,
}: RequestRealtimeBarsArgs): Promise<Candle | undefined> {
if (getBarsResolver(ticker)) {
return;
}
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий инструмент совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
const lowerCaseKey = transformKeyToLowerCase(currencyPair);
if (!lowerCaseKey) {
return undefined;
}
try {
const { data } = await indicativeQuotesController.getCandles({
count: 1,
key: lowerCaseKey,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DDTHH:mm:ss'),
interval,
});
if (data.indicativeCandles.length === 0) {
return undefined;
}
const sortedData = [...data.indicativeCandles].sort(
(first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
);
const firstCandle = sortedData[0];
if (!firstCandle) {
return undefined;
}
const bar = candleToBar(firstCandle);
onRealtimeCallback?.(bar);
return bar;
} catch (error) {
console.error('error from requestRealTimeBars indicativeQuotesController: ', error);
return undefined;
}
}
try {
const { data } = await api.getBars({
currencyPair,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DD%20HH:mm:ss'),
interval,
count: 1,
ticker,
});
if (data.length === 0) {
return undefined;
}
const sortedData = [...data].sort(
(first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
);
const firstCandle = sortedData[0];
if (!firstCandle) {
return undefined;
}
const bar = candleToBar(firstCandle);
onRealtimeCallback?.(bar);
return bar;
} catch (error) {
console.error('error from requestRealTimeBars: ', error);
return undefined;
}
}