Загрузка данных
import type { Timeframes as TimeframesType } from 'moex-chart';
type DataSourceProvideModule = typeof import('@widgets/Chart/components/MoexChart/dataSourceProvide');
const mockRequestBars = jest.fn();
const mockRequestRealtimeBars = jest.fn();
const mockMoexChartTimeConverter = jest.fn();
const mockParseTimeframe = jest.fn();
const mockGetStartTime = jest.fn();
const mockMoexChartToIssTimeframe = jest.fn();
const mockGetISSOddTimeframePart = jest.fn();
// Mock the dependencies
jest.mock('moex-chart', () => ({
Timeframes: {
'10s': '10s',
'1m': '1m',
'5m': '5m',
},
parseTimeframe: mockParseTimeframe,
getStartTime: mockGetStartTime,
}));
jest.mock('@utils/chartToReqTimeConverter', () => ({
moexChartTimeConverter: mockMoexChartTimeConverter,
moexChartToIssTimeframe: mockMoexChartToIssTimeframe,
getISSOddTimeframePart: mockGetISSOddTimeframePart,
}));
jest.mock('../requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
jest.mock('@widgets/Chart/requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
const { DataSourceProvider } = jest.requireActual(
'@widgets/Chart/components/MoexChart/dataSourceProvide',
) as DataSourceProvideModule;
const Timeframes = {
'10s': '10s' as TimeframesType,
'1m': '1m' as TimeframesType,
'5m': '5m' as TimeframesType,
};
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
describe('DataSourceProvider', () => {
const mockBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-19T10:00:00Z'));
mockMoexChartTimeConverter.mockReturnValue('1');
mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) => timeframe);
mockGetISSOddTimeframePart.mockReturnValue({
value: 0,
unit: 'minute',
});
mockParseTimeframe.mockReturnValue({
candleWidth: 1,
dayjsUnit: 'minute',
});
mockGetStartTime.mockImplementation((_timeframe: TimeframesType, time: number) => Math.floor(time / 1000));
});
afterEach(() => {
jest.useRealTimers();
});
it('should request chart history data with converted timeframe', async () => {
// Arrange
const mockTimeframeCallback = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockTimeframeCallback).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockMoexChartTimeConverter).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockRequestBars).toHaveBeenCalledWith({
currencyPair: 'MOEX.SBER',
interval: '1',
periodParams: {
firstDataRequest: true,
to: Math.round(Date.now() / 1000),
from: Date.now(),
countBack: 2000,
},
ticker: 'MOEX:SBER',
indicativeData: undefined,
});
expect(result).toEqual([mockBar]);
});
it('should request chart history data with indicative data', async () => {
// Arrange
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(indicativeData);
// Act
await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should use until time when it is provided', async () => {
// Arrange
mockMoexChartTimeConverter.mockReturnValue('5');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = { time: 111 } as NonNullable<Parameters<typeof dataSource>[2]>;
// Act
await dataSource(Timeframes['5m'], 'MOEX:GAZP', until);
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
periodParams: expect.objectContaining({
to: 111,
}),
}),
);
});
it('should return null when history data is empty', async () => {
// Arrange
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(result).toBeNull();
});
it('should request realtime data and update normalized symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
const localMockedBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: Math.floor(Math.random() * 1000),
};
mockRequestRealtimeBars.mockImplementation(() => localMockedBar);
provider.startRealtime({
getSymbols: () => [' moex:sber '],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
currencyPair: 'MOEX.SBER',
interval: '1',
ticker: 'MOEX:SBER',
indicativeData: undefined,
});
expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', localMockedBar);
});
it('should request realtime data with indicative data', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
indicativeData,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should not call update when realtime data is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(undefined);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data when symbols list is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => [],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should clear realtime timer on unsubscribe', async () => {
// Arrange
const provider = new DataSourceProvider();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
unsubscribe();
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
});
import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';
import { requestBars, requestRealtimeBars } from '../requestBars';
import { isIndicativeTicker } from '../utils/isIndicativeTicker';
// Mock the dependencies
jest.mock('../utils/isIndicativeTicker');
jest.mock('@utils/candleToBar');
// Mock the api module
jest.mock('@api/index', () => ({
__esModule: true,
default: {
getBars: jest.fn(),
},
}));
// Mock the indicativeQuotesController module
jest.mock('@api/controllers/indicativeQuotesController', () => ({
indicativeQuotesController: {
getCandles: jest.fn(),
},
}));
describe('requestBars', () => {
const mockPeriodParams = {
countBack: 100,
from: 1640195200,
to: 1640995200, // 2022-01-01T00:00:00Z
firstDataRequest: true,
};
const mockCandle = {
open: 100,
close: 110,
high: 120,
low: 90,
ticker: 'TEST',
volume: 1000,
end: '2022-01-01T10:00:00Z',
interval: '1',
};
const mockBar = {
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
time: 1640995200000,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle indicative instrument correctly when indicativeData matches ticker', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockIndicativeData = {
key: 'test-ticker',
secId: 'string',
instrumentName: 'string',
settlement: 'string',
firmName: 'string',
};
const mockResponse = {
data: {
indicativeCandles: [mockCandle],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
ticker: 'test-ticker',
indicativeData: mockIndicativeData,
});
// Assert
expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
count: mockPeriodParams.countBack,
key: 'USD/RUB',
date: '2022-01-01T00:00:00',
interval: '1',
});
expect(result).toEqual([mockBar]);
});
it('should handle indicative instrument correctly when ticker contains indicative board', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: {
indicativeCandles: [mockCandle],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
ticker: 'TEST.indicative_spot',
});
// Assert
expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
count: mockPeriodParams.countBack,
key: 'USD/RUB',
date: '2022-01-01T00:00:00',
interval: '1',
});
expect(result).toEqual([mockBar]);
});
it('should handle non-indicative instrument correctly', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: [mockCandle],
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
ticker: 'TEST',
});
// Assert
expect(api.getBars).toHaveBeenCalledWith({
currencyPair: 'USD/RUB',
date: '2022-01-01%2000:00:00',
interval: '1',
count: mockPeriodParams.countBack,
ticker: 'TEST',
});
expect(result).toEqual([mockBar]);
});
it('should call onHistoryCallback with data when indicative instrument succeeds', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: {
indicativeCandles: [mockCandle],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
const mockHistoryCallback = jest.fn();
// Act
await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
onHistoryCallback: mockHistoryCallback,
ticker: 'TEST.indicative_spot',
});
// Assert
expect(mockHistoryCallback).toHaveBeenCalledWith([mockBar], { noData: false });
});
it('should call onHistoryCallback with empty array and noData flag when indicative instrument fails', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(
indicativeQuotesController.getCandles as unknown as { mockRejectedValue: (mockResponse: object) => void }
).mockRejectedValue(new Error('API Error'));
const mockHistoryCallback = jest.fn();
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
onHistoryCallback: mockHistoryCallback,
ticker: 'TEST.indicative_spot',
});
// Assert
expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
expect(result).toEqual([]);
});
it('should call onHistoryCallback with empty array and noData flag when non-indicative instrument fails', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(api.getBars as unknown as { mockRejectedValue: (mockResponse: object) => void }).mockRejectedValue(
new Error('API Error'),
);
const mockHistoryCallback = jest.fn();
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
onHistoryCallback: mockHistoryCallback,
ticker: 'TEST',
});
// Assert
expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
expect(result).toEqual([]);
});
it('should handle multiple candles for indicative instrument', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockCandles = [
{ ...mockCandle, end: '2022-01-01T10:00:00Z' },
{ ...mockCandle, end: '2022-01-01T11:00:00Z' },
{ ...mockCandle, end: '2022-01-01T12:00:00Z' },
];
const mockResponse = {
data: {
indicativeCandles: mockCandles,
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
ticker: 'TEST.indicative_spot',
});
// Assert
expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
count: mockPeriodParams.countBack,
key: 'USD/RUB',
date: '2022-01-01T00:00:00',
interval: '1',
});
expect(candleToBar).toHaveBeenCalledTimes(3);
expect(result).toEqual([mockBar, mockBar, mockBar]);
});
it('should handle multiple candles for non-indicative instrument', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockCandles = [
{ ...mockCandle, end: '2022-01-01T10:00:00Z' },
{ ...mockCandle, end: '2022-01-01T11:00:00Z' },
{ ...mockCandle, end: '2022-01-01T12:00:00Z' },
];
const mockResponse = {
data: mockCandles,
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
ticker: 'TEST',
});
// Assert
expect(api.getBars).toHaveBeenCalledWith({
currencyPair: 'USD/RUB',
date: '2022-01-01%2000:00:00',
interval: '1',
count: mockPeriodParams.countBack,
ticker: 'TEST',
});
expect(candleToBar).toHaveBeenCalledTimes(3);
expect(result).toEqual([mockBar, mockBar, mockBar]);
});
it('should handle empty indicativeCandles array', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
const mockResponse = {
data: {
indicativeCandles: [],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
const mockHistoryCallback = jest.fn();
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
onHistoryCallback: mockHistoryCallback,
ticker: 'TEST.indicative_spot',
});
// Assert
expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
expect(result).toEqual([]);
});
it('should handle empty bars array for non-indicative instrument', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
const mockResponse = {
data: [],
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
const mockHistoryCallback = jest.fn();
// Act
const result = await requestBars({
currencyPair: 'USD/RUB',
interval: '1',
periodParams: mockPeriodParams,
onHistoryCallback: mockHistoryCallback,
ticker: 'TEST',
});
// Assert
expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
expect(result).toEqual([]);
});
});
describe('requestRealtimeBars', () => {
const mockCandle = {
open: 100,
close: 110,
high: 120,
low: 90,
ticker: 'TEST',
volume: 1000,
end: '2022-01-01T10:00:00Z',
interval: '1',
};
const mockBar = {
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
time: 1640995200000,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle indicative instrument correctly', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: {
indicativeCandles: [mockCandle],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
// Act
const result = await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST.indicative_spot',
});
// Assert
expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
count: 1,
key: 'USD/RUB',
date: expect.any(String), // We can't predict the exact date string
interval: '1',
});
expect(candleToBar).toHaveBeenCalledWith(mockCandle);
expect(result).toEqual(mockBar);
});
it('should handle non-indicative instrument correctly', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: [mockCandle],
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
// Act
const result = await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST',
});
// Assert
expect(api.getBars).toHaveBeenCalledWith({
currencyPair: 'USD/RUB',
date: expect.any(String), // We can't predict the exact date string
interval: '1',
count: 1,
ticker: 'TEST',
});
expect(candleToBar).toHaveBeenCalledWith(mockCandle);
expect(result).toEqual(mockBar);
});
it('should handle empty indicativeCandles array in realtime request', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
const mockResponse = {
data: {
indicativeCandles: [],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
// Act
const result = await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST.indicative_spot',
});
// Assert
expect(result).toBeUndefined();
});
it('should handle empty bars array in realtime request', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
const mockResponse = {
data: [],
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
// Act
const result = await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST',
});
// Assert
expect(result).toBeUndefined();
});
it('should call onRealtimeCallback with bar when indicative instrument succeeds', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: {
indicativeCandles: [mockCandle],
},
};
(
indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
).mockResolvedValue(mockResponse);
const mockRealtimeCallback = jest.fn();
// Act
await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST.indicative_spot',
onRealtimeCallback: mockRealtimeCallback,
});
// Assert
expect(mockRealtimeCallback).toHaveBeenCalledWith(mockBar);
});
it('should call onRealtimeCallback with bar when non-indicative instrument succeeds', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(candleToBar as jest.Mock).mockReturnValue(mockBar);
const mockResponse = {
data: [mockCandle],
};
(api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);
const mockRealtimeCallback = jest.fn();
// Act
await requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST',
onRealtimeCallback: mockRealtimeCallback,
});
// Assert
expect(mockRealtimeCallback).toHaveBeenCalledWith(mockBar);
});
it('should handle error in indicative instrument request', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(true);
(
indicativeQuotesController.getCandles as unknown as { mockRejectedValue: (mockResponse: object) => void }
).mockRejectedValue(new Error('API Error'));
const mockRealtimeCallback = jest.fn();
// Act & Assert
await expect(
requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST.indicative_spot',
onRealtimeCallback: mockRealtimeCallback,
}),
).resolves.toBeUndefined();
});
it('should handle error in non-indicative instrument request', async () => {
// Arrange
(isIndicativeTicker as jest.Mock).mockReturnValue(false);
(api.getBars as unknown as { mockRejectedValue: (mockResponse: object) => void }).mockRejectedValue(
new Error('API Error'),
);
const mockRealtimeCallback = jest.fn();
// Act & Assert
await expect(
requestRealtimeBars({
currencyPair: 'USD/RUB',
interval: '1',
ticker: 'TEST',
onRealtimeCallback: mockRealtimeCallback,
}),
).resolves.toBeUndefined();
});
});
import { render } from '@testing-library/react';
import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import { SymbolSearchModal } from '../components/MoexChart/components/SymbolSearchModal';
import type { Contract } from '@modules/contracts';
jest.mock('@components/InstrumentSearch', () => ({
InstrumentSearch: jest.fn(() => null),
}));
interface InstrumentSearchMockProps {
widgetId: number;
variant: string;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
addInstruments: (instruments: Contract[]) => void;
}
describe('SymbolSearchModal', () => {
const mockInstrumentSearch = InstrumentSearch as jest.Mock;
const mockSetOpen = jest.fn();
const mockOnSymbolChange = jest.fn();
const renderComponent = (): InstrumentSearchMockProps => {
render(
<SymbolSearchModal
widgetId={42}
isOpen
setOpen={mockSetOpen}
onSymbolChange={mockOnSymbolChange}
/>,
);
return mockInstrumentSearch.mock.calls[0]?.[0] as InstrumentSearchMockProps;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass modal properties to instrument search', () => {
// Arrange & Act
const instrumentSearchProps = renderComponent();
// Assert
expect(instrumentSearchProps.widgetId).toBe(42);
expect(instrumentSearchProps.variant).toBe('single');
expect(instrumentSearchProps.isOpen).toBe(true);
expect(instrumentSearchProps.setOpen).toBe(mockSetOpen);
expect(instrumentSearchProps.addInstruments).toEqual(expect.any(Function));
});
it('should change symbol after instrument selection', () => {
// Arrange
const instrumentSearchProps = renderComponent();
const instrument = {
issKey: 'MOEX:SBER',
} as Contract;
// Act
instrumentSearchProps.addInstruments([instrument]);
// Assert
expect(mockOnSymbolChange).toHaveBeenCalledTimes(1);
expect(mockOnSymbolChange).toHaveBeenCalledWith('MOEX:SBER');
});
it('should close modal after instrument selection', () => {
// Arrange
const instrumentSearchProps = renderComponent();
const instrument = {
issKey: 'MOEX:SBER',
} as Contract;
// Act
instrumentSearchProps.addInstruments([instrument]);
// Assert
expect(mockSetOpen).toHaveBeenCalledTimes(1);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should not change symbol when instruments list is empty', () => {
// Arrange
const instrumentSearchProps = renderComponent();
// Act
instrumentSearchProps.addInstruments([]);
// Assert
expect(mockOnSymbolChange).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
it('should not change symbol when selected instrument has no issKey', () => {
// Arrange
const instrumentSearchProps = renderComponent();
// Act
instrumentSearchProps.addInstruments([{} as Contract]);
// Assert
expect(mockOnSymbolChange).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
it('should not change symbol when selected instrument has empty issKey', () => {
// Arrange
const instrumentSearchProps = renderComponent();
const instrument = {
issKey: '',
} as Contract;
// Act
instrumentSearchProps.addInstruments([instrument]);
// Assert
expect(mockOnSymbolChange).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
import { renderHook } from '@testing-library/react';
import { useAppSelect } from '@hooks/useAppSelector';
import { useContracts } from '@modules/contracts';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { useChartPublicContext } from '../hooks/useChartPublicContext';
// Mock the dependencies
jest.mock('@hooks/useAppSelector');
jest.mock('@modules/contracts');
jest.mock('@utils/filterByUniqIssKey');
jest.mock('@api/index', () => ({
updateMenuLocked: jest.fn(),
widgetsController: {
delete: jest.fn(),
},
widgetPropertiesController: {
update: jest.fn(),
},
workspaceController: {
update: jest.fn(),
},
}));
jest.mock('@api/controllers/workspace', () => ({
workspaceController: {
update: jest.fn(),
},
}));
describe('useChartPublicContext', () => {
const mockUseAppSelect = useAppSelect as jest.Mock;
const mockUseContracts = useContracts as jest.Mock;
const mockFilterByUniqIssKey = filterByUniqIssKey as jest.Mock;
const mockContracts = [
{
issKey: 'MOEX:TEST1',
instrName: 'Test Instrument 1',
symbol: 'TEST1',
// ... other contract properties
},
{
issKey: 'MOEX:TEST2',
instrName: 'Test Instrument 2',
symbol: 'TEST2',
// ... other contract properties
},
{
issKey: 'MOEX:TEST3',
instrName: 'Test Instrument 3',
symbol: 'TEST3',
// ... other contract properties
},
];
const mockWidget = {
id: 1,
name: 'Test Widget',
type: 'graphic',
master: 123,
externalProperties: [{ key: 'instrument', value: 'MOEX:TEST1' }],
// ... other widget properties
};
const mockPublicContext = {
instrument: 'MOEX:TEST1',
};
beforeEach(() => {
jest.clearAllMocks();
// Mock the useAppSelect to return our test data
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return mockWidget;
}
return {};
});
// Mock useContracts to return our test contracts
mockUseContracts.mockReturnValue({
contracts: mockContracts,
});
// Mock filterByUniqIssKey to return the same contracts
mockFilterByUniqIssKey.mockImplementation((contracts, keys) => contracts);
});
it('should return the correct issKey when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
});
it('should return undefined when no matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:NONEXISTENT');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns null', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns undefined', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(undefined);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should set current instrument to DEFAULT_SYMBOL when no field value and widget has master', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledWith(DEFAULT_SYMBOL);
});
it('should set current instrument to the found issKey when a matching instrument exists', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST2');
});
it('should handle case when widget is not found in widgets array', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Mock useAppSelect to return null for widget (widget not found)
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return null; // Widget not found
}
return {};
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 999, // Non-existent widget ID
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
});
it('should handle case when contracts array is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Mock useContracts to return empty contracts
mockUseContracts.mockReturnValue({
contracts: [],
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
});
import { act, render } from '@testing-library/react';
import { MoexChart, Timeframes } from 'moex-chart';
import React from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { ChartIndicativeData } from '@widgets/Chart/types';
import { useMoexChart } from '../components/MoexChart/hooks';
// Mock the dependencies
jest.mock('@modules/widgetProperties');
jest.mock('../components/MoexChart/dataSourceProvide', () => ({
DataSourceProvider: jest.fn(),
}));
jest.mock('moex-chart', () => ({
MoexChart: jest.fn(),
Timeframes: {
'10s': '10s',
'1m': '1m',
'5m': '5m',
},
DateFormat: {
DD_MM_YYYY_HH_mm_ss: 'DD_MM_YYYY_HH_mm_ss',
},
IndicatorsIds: {
Volume: 'Volume',
},
}));
jest.mock('@api/index', () => ({
updateMenuLocked: jest.fn(),
widgetsController: {
delete: jest.fn(),
},
widgetPropertiesController: {
update: jest.fn(),
},
workspaceController: {
update: jest.fn(),
},
}));
jest.mock('@api/controllers/workspace', () => ({
workspaceController: {
update: jest.fn(),
},
}));
const mockedDataSourceProvide = jest.requireMock('../components/MoexChart/dataSourceProvide') as {
DataSourceProvider: jest.Mock;
};
type TimeframeValue = (typeof Timeframes)[keyof typeof Timeframes];
interface MockPaneSnapshot {
indicators: unknown[];
}
interface MockChartSnapshot {
charts: {
symbol: string;
timeframe: TimeframeValue;
chartSeriesType: string;
panes: MockPaneSnapshot[];
}[];
}
interface MockMoexChartConfig {
container: HTMLElement;
snapshot: MockChartSnapshot;
chartCollectionPreset: {
openCompareModal: () => void;
openSymbolSearchModal: () => void;
getDataSource: jest.Mock;
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => TimeframeValue,
update: jest.Mock,
) => (() => void) | undefined;
};
}
interface MockMoexChartState {
tf?: TimeframeValue;
savedData?: string;
}
interface MockPropertiesState {
moexChartState?: MockMoexChartState;
}
type UpdatePropertiesCallback = (state: MockPropertiesState) => void;
type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;
interface TestComponentProps {
symbol?: string;
}
describe('useMoexChart', () => {
const mockUseSelectProperties = useSelectProperties as jest.Mock;
const mockUseChangeProperties = useChangeProperties as jest.Mock;
const mockMoexChart = MoexChart as jest.Mock;
const mockDataSourceProvider = mockedDataSourceProvide.DataSourceProvider;
const mockUpdateProperties = jest.fn();
const mockDestroy = jest.fn();
const mockGetSnapshot = jest.fn();
const mockSetSnapshot = jest.fn();
const mockSetSymbol = jest.fn();
const mockGetCompareManager = jest.fn();
const mockDataSource = jest.fn();
const mockStartRealtime = jest.fn();
const mockGetDataSource = jest.fn();
const mockRealtimeUnsubscribe = jest.fn();
const mockCompareManager = {
id: 'compare-manager',
};
const mockSnapshot: MockChartSnapshot = {
charts: [
{
symbol: 'OLD:SYMBOL',
timeframe: Timeframes['5m'],
chartSeriesType: 'Candlestick',
panes: [
{
indicators: [],
},
],
},
],
};
let hookResult: ReturnType<typeof useMoexChart> | null = null;
let lastMoexChartConfig: MockMoexChartConfig | null = null;
let mockMoexChartState: MockMoexChartState | undefined;
const TestComponent = ({ symbol = 'MOEX:SBER' }: TestComponentProps): React.ReactElement => {
hookResult = useMoexChart({
symbol,
indicativeData: undefined,
});
return <div ref={hookResult.containerRef} />;
};
beforeEach(() => {
jest.clearAllMocks();
hookResult = null;
lastMoexChartConfig = null;
mockMoexChartState = {
tf: Timeframes['1m'],
savedData: undefined,
};
mockUseSelectProperties.mockImplementation((selector: (state: MockPropertiesState) => unknown) =>
selector({
moexChartState: mockMoexChartState,
}),
);
mockUseChangeProperties.mockReturnValue({
updateProperties: mockUpdateProperties,
});
mockGetDataSource.mockImplementation(
(_indicativeData?: ChartIndicativeData, callback?: (timeframe: Timeframes) => void) =>
(timeframe: Timeframes) => {
callback?.(timeframe);
return mockDataSource();
},
);
mockStartRealtime.mockReturnValue(mockRealtimeUnsubscribe);
mockDataSourceProvider.mockImplementation(() => ({
getDataSource: mockGetDataSource,
startRealtime: mockStartRealtime,
}));
mockGetSnapshot.mockReturnValue(mockSnapshot);
mockGetCompareManager.mockReturnValue(mockCompareManager);
mockMoexChart.mockImplementation((config: MockMoexChartConfig) => {
lastMoexChartConfig = config;
return {
destroy: mockDestroy,
getSnapshot: mockGetSnapshot,
setSnapshot: mockSetSnapshot,
setSymbol: mockSetSymbol,
getCompareManager: mockGetCompareManager,
};
});
});
it('should create moex chart with current symbol and timeframe', () => {
// Arrange & Act
render(<TestComponent symbol="MOEX:SBER" />);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
expect(mockDataSourceProvider).toHaveBeenCalledTimes(1);
expect(lastMoexChartConfig?.container).toBeInstanceOf(HTMLDivElement);
expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:SBER');
expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(Timeframes['1m']);
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
});
it('should create chart with saved snapshot when saved data exists', () => {
// Arrange
mockMoexChartState = {
tf: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
// Act
render(<TestComponent symbol="MOEX:GAZP" />);
// Assert
expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:GAZP');
expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(Timeframes['5m']);
expect(hookResult?.hasSavedSnapshot).toBe(true);
});
it('should save chart snapshot to widget properties', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.saveSnapshot();
});
// Assert
expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
tf: Timeframes['1m'],
},
};
updateCallback(mockState);
expect(mockState.moexChartState).toEqual({
tf: Timeframes['1m'],
savedData: JSON.stringify(mockSnapshot),
});
});
it('should not save snapshot when chart does not return snapshot', () => {
// Arrange
mockGetSnapshot.mockReturnValue(undefined);
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.saveSnapshot();
});
// Assert
expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
expect(mockUpdateProperties).not.toHaveBeenCalled();
});
it('should apply saved snapshot with current symbol and timeframe', () => {
// Arrange
mockMoexChartState = {
tf: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbol: 'MOEX:SBER',
timeframe: Timeframes['5m'],
},
],
});
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
});
it('should update compare modal state', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.setIsCompareOpen(true);
});
// Assert
expect(hookResult?.isCompareOpen).toBe(true);
});
it('should open compare modal from chart preset callback', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
lastMoexChartConfig?.chartCollectionPreset.openCompareModal();
});
// Assert
expect(hookResult?.isCompareOpen).toBe(true);
});
it('should update symbol search modal state', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.setIsSymbolSearchOpen(true);
});
// Assert
expect(hookResult?.isSymbolSearchOpen).toBe(true);
});
it('should open symbol search modal from chart preset callback', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
lastMoexChartConfig?.chartCollectionPreset.openSymbolSearchModal();
});
// Assert
expect(hookResult?.isSymbolSearchOpen).toBe(true);
});
it('should normalize and change main symbol', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.setMainSymbol(' MOEX:GAZP ');
});
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:GAZP');
});
it('should not change main symbol when value is empty', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.setMainSymbol('');
hookResult?.setMainSymbol(' ');
});
// Assert
expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should not change main symbol when it is already selected', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Act
act(() => {
hookResult?.setMainSymbol('MOEX:SBER');
});
// Assert
expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should update chart when external symbol changes', () => {
// Arrange
const { rerender } = render(<TestComponent symbol="MOEX:SBER" />);
// Act
rerender(<TestComponent symbol="MOEX:GAZP" />);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:GAZP');
});
it('should not recreate chart when external symbol changes', () => {
// Arrange
const { rerender } = render(<TestComponent symbol="MOEX:SBER" />);
// Act
rerender(<TestComponent symbol="MOEX:GAZP" />);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
});
it('should apply saved snapshot with symbol selected from search modal', () => {
// Arrange
mockMoexChartState = {
tf: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
render(<TestComponent symbol="MOEX:SBER" />);
act(() => {
hookResult?.setMainSymbol('MOEX:GAZP');
});
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbol: 'MOEX:GAZP',
timeframe: Timeframes['5m'],
},
],
});
});
it('should pass realtime params to data source provider', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
const getSymbols = jest.fn(() => ['MOEX:SBER']);
const getTimeframe = jest.fn(() => Timeframes['1m']);
const update = jest.fn();
// Act
const unsubscribe = lastMoexChartConfig?.chartCollectionPreset.startRealtime(getSymbols, getTimeframe, update);
// Assert
expect(mockStartRealtime).toHaveBeenCalledWith({
getSymbols,
getTimeframe,
update,
});
expect(unsubscribe).toBe(mockRealtimeUnsubscribe);
});
it('should update timeframe from data source callback', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
// Act
act(() => {
timeframeChangeCallback(Timeframes['5m']);
});
// Assert
expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
tf: Timeframes['1m'],
},
};
updateCallback(mockState);
expect(mockState.moexChartState?.tf).toBe(Timeframes['5m']);
});
it('should not update timeframe when it is the same as current timeframe', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
// Act
act(() => {
timeframeChangeCallback(Timeframes['1m']);
});
// Assert
expect(mockUpdateProperties).not.toHaveBeenCalled();
});
it('should destroy chart on unmount', () => {
// Arrange
const { unmount } = render(<TestComponent symbol="MOEX:SBER" />);
// Act
unmount();
// Assert
expect(mockDestroy).toHaveBeenCalledTimes(1);
});
});
import { __CompareManager__, CompareMode } from 'moex-chart';
import React, { MutableRefObject, useEffect, useState } from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
export const CompareModal = ({
compareManager,
widgetId,
isOpen,
setOpen,
}: {
onClose: () => void;
widgetId: number;
compareManager: MutableRefObject<__CompareManager__ | null>;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
}) => {
const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);
useEffect(() => {
const manager = compareManager.current;
if (!isOpen || !manager) {
setIsNewScaleDisabled(false);
return;
}
setIsNewScaleDisabled(manager.isNewScaleDisabled());
const subscription = manager.isNewScaleDisabledObservable().subscribe(setIsNewScaleDisabled);
return () => subscription.unsubscribe();
}, [compareManager, isOpen]);
const handlePercent = (symbol: string) => {
compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.Percentage);
};
const handleNewScale = (symbol: string) => {
compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewScale);
};
const handleNewPanel = (symbol: string) => {
compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewPane);
};
return (
<InstrumentSearch
setOpen={setOpen}
isOpen={isOpen}
variant="single"
widgetId={widgetId}
// withNRD={false}
// Временный костыль, пока не завезем свой поиск интструментов
addInstruments={() => {
// nothing
}}
isNewScaleDisabled={isNewScaleDisabled}
customActionsFooterHandlers={{
handlePercent,
handleNewScale,
handleNewPanel,
}}
/>
);
};
import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import type { Contract } from '@modules/contracts';
interface SymbolSearchModalProps {
widgetId: number;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
onSymbolChange: (symbol: string) => void;
}
export const SymbolSearchModal = ({ widgetId, isOpen, setOpen, onSymbolChange }: SymbolSearchModalProps) => {
const handleAddInstruments = (instruments: Contract[]) => {
const symbol = instruments[0]?.issKey;
if (!symbol) {
return;
}
onSymbolChange(symbol);
setOpen(false);
};
return (
<InstrumentSearch
widgetId={widgetId}
variant="single"
isOpen={isOpen}
setOpen={setOpen}
addInstruments={handleAddInstruments}
/>
);
};
import { MoexChart, Timeframes } from 'moex-chart';
import { useEffect, useRef, useState } from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { ChartIndicativeData } from '@widgets/Chart/types';
import { WidgetProperties } from '../../../properties/types';
import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
import type { __CompareManager__, IMoexChart } from 'moex-chart';
type TUseMoexChartProps = {
symbol: string;
indicativeData: ChartIndicativeData | undefined;
};
export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties((wProps: Partial<WidgetProperties>) => wProps.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<null | __CompareManager__>(null);
const currentSymbolRef = useRef(symbol);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.tf);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
const updateTimeframeRef = useRef<((tf: Timeframes) => void) | null>(null);
useEffect(() => {
if (!symbol || currentSymbolRef.current === symbol) {
return;
}
currentSymbolRef.current = symbol;
chartRef.current?.setSymbol(symbol);
}, [symbol]);
const setMainSymbol = (nextSymbol: string) => {
const normalizedSymbol = nextSymbol.trim();
if (!normalizedSymbol || currentSymbolRef.current === normalizedSymbol) {
return;
}
currentSymbolRef.current = normalizedSymbol;
chartRef.current?.setSymbol(normalizedSymbol);
};
useEffect(() => {
timeframeRef.current = moexChartState?.tf;
savedDataRef.current = moexChartState?.savedData;
}, [moexChartState]);
updateTimeframeRef.current = (tf: Timeframes) => {
if (timeframeRef.current === tf) {
return;
}
timeframeRef.current = tf;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
tf,
};
});
};
const saveSnapshot = () => {
const snapshot = chartRef.current?.getSnapshot();
if (!snapshot) {
return;
}
const savedData = JSON.stringify({
...snapshot,
charts: snapshot.charts.map((c) => ({
...c,
panes: c.panes.map((p) => ({
...p,
indicators: p.indicators.map((i) => ({
...i,
dataSource: undefined, // dataSource пока не среиализуем
config: undefined, // config пока не среиализуем
})),
})),
})),
});
savedDataRef.current = savedData;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
tf: timeframeRef.current || Timeframes['1m'],
savedData,
};
});
};
const applySnapshot = () => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current || Timeframes['1m'];
chartRef.current.setSnapshot({
...savedSnapshot,
charts: savedSnapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
symbol: currentSymbolRef.current,
timeframe,
})),
});
compareManagerRef.current = chartRef.current.getCompareManager();
};
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const timeframe = timeframeRef.current || Timeframes['1m'];
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: {
...savedSnapshot,
charts: savedSnapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
symbol: currentSymbolRef.current,
timeframe,
})),
},
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
getDataSource: dataProvider.getDataSource(indicativeData, (tf) => {
updateTimeframeRef.current?.(tf);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
getSymbols,
getTimeframe,
update,
}),
},
});
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
return () => {
chartRef.current = null;
clearInterval(intervalId);
compareManagerRef.current = null;
chart.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- исправим позже
}, [indicativeData]);
return {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
setMainSymbol,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
};
};
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()
.toUpperCase();
}
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,
);
let aggregatedCandle: Candle | undefined;
let offset = 0;
let consumedCount = 0;
while (offset < iterationCounter) {
const currentCandle = data[index + 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 (index + offset + 1 === data.length || currentCandle.time > startTime * 1000) {
break;
}
offset += 1;
}
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 };
import React from 'react';
import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';
import { ChartIndicativeData } from '../../types';
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
import 'moex-chart/dist/styles.css';
type TRProps = {
fullName: string;
indicativeData?: ChartIndicativeData | undefined;
widgetId: number;
};
export default React.memo(({ fullName, indicativeData, widgetId }: TRProps) => {
const {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
setMainSymbol,
} = useMoexChart({
indicativeData,
symbol: fullName,
});
return (
<div
style={{
flex: '1 1 0',
minHeight: 0,
minWidth: 0,
}}
>
<div ref={containerRef} />
{isCompareOpen && (
<CompareModal
onClose={() => setIsCompareOpen(false)}
compareManager={compareManagerRef}
widgetId={widgetId}
isOpen={isCompareOpen}
setOpen={setIsCompareOpen}
/>
)}
{isSymbolSearchOpen && (
<SymbolSearchModal
widgetId={widgetId}
isOpen={isSymbolSearchOpen}
setOpen={setIsSymbolSearchOpen}
onSymbolChange={setMainSymbol}
/>
)}
</div>
);
});
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 { getState } from '@store/store';
import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { Contract } from '@modules/contracts';
import type { Dispatch, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps } from '../types';
interface UseChartComponentFacadeReturn {
dropDownOpen: boolean;
setDropdownOpen: Dispatch<SetStateAction<boolean>>;
currentInstrument: string;
isWidgetHeaderContextMenuOpen: boolean;
setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
onDropInstruments: (val: string, withUpdate?: boolean) => void;
addInstrumentFromModal: (instruments: Pick<Contract, 'issKey'>[]) => void;
isOver: boolean;
}
type SaveContentPropsOptions = Partial<WidgetProperties['chartState']> & {
withUpdate?: boolean;
cleanIndicativeData?: boolean;
};
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;
const initialInstrument = widgetProperties?.chartState?.savedInstrument ?? DEFAULT_SYMBOL;
// TODO временно реф из-за непредсказуемого изменения если используется useState,
// нужен рефакторинг и вернуть обратно useState
const currentInstrumentRef = useRef(initialInstrument);
const [currentInstrument, setCurrentInstrument] = useState(initialInstrument);
const { triggerRelatedWidgetsToUpdate, getMasterInstrumentFromPublicContext } = useWidgetsBind({
widgetId,
});
const triggerRelatedWidgetsToUpdateRef = useRef(triggerRelatedWidgetsToUpdate);
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current = triggerRelatedWidgetsToUpdate;
}, [triggerRelatedWidgetsToUpdate]);
const saveContentProps = useCallback(
(val: SaveContentPropsOptions): void => {
const { withUpdate, cleanIndicativeData, ...chartStateVal } = val;
const oldProps = (getState().widgets.widgets.find(({ id }) => id === widgetId) as Widget | undefined)
?.widgetContentProps;
if (!oldProps) {
return;
}
dispatch(
addContentPropsToWidget({
id: widgetId,
withoutSend: !withUpdate,
widgetContentProps: {
chartState: {
...oldProps.chartState,
...chartStateVal,
},
// при смене инструмента очищаем индикативные данные, переданные из виджета Индикативные котировки
indicativeData: cleanIndicativeData ? undefined : oldProps.indicativeData,
},
}),
);
},
[dispatch, widgetId],
);
const onInstrumentChange = useCallback(
(newVal: string, withUpdate?: boolean, unbind = true): void => {
if (!newVal) {
return;
}
setIsWidgetHeaderContextMenuOpen(false);
if (currentInstrumentRef.current === newVal) {
return;
}
currentInstrumentRef.current = newVal;
setCurrentInstrument(newVal);
if (unbind) {
dispatch(unbindWidgets({ widgetId }));
}
// при смене инструмента очищаем индикативные данные виджета график
// т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
saveContentProps({
savedInstrument: newVal,
withUpdate,
cleanIndicativeData: true,
});
triggerRelatedWidgetsToUpdateRef.current(newVal);
},
[dispatch, saveContentProps, widgetId],
);
const addInstrumentFromModal = useCallback(
(instruments: Pick<Contract, 'issKey'>[]) => {
const issKey = instruments[0]?.issKey;
if (!issKey) {
return;
}
onInstrumentChange(issKey);
},
[onInstrumentChange],
);
const onInstrumentChangeFromBind = useCallback(
(instrumentId: string) => {
onInstrumentChange(instrumentId, true, false);
},
[onInstrumentChange],
);
const onDropInstruments = useCallback(
(val: string, withUpdate?: boolean): void => {
onInstrumentChange(val, withUpdate);
},
[onInstrumentChange],
);
useChartPublicContext({
setCurrInstrument: onInstrumentChangeFromBind,
widgetId,
getMasterInstrumentFromPublicContext,
});
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current(currentInstrumentRef.current);
}, []);
useEffect(() => {
const unsubscribe = communicator.listen(
{
messageType: CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
},
(message) => {
const issKey = (message as Record<number, string>)[widgetId];
if (issKey) {
addInstrumentFromModal([{ issKey }]);
}
},
);
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();
};
}, [addInstrumentFromModal, widgetId]);
return {
dropDownOpen,
setDropdownOpen,
currentInstrument,
isWidgetHeaderContextMenuOpen,
setIsWidgetHeaderContextMenuOpen,
onDropInstruments,
addInstrumentFromModal,
isOver,
};
}
import { useEffect, useMemo } from 'react';
import { useAppSelect } from '@hooks/useAppSelector';
import { Contract, useContracts } from '@modules/contracts';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { Widget } from 'types/Widgets';
interface UseChartPublicContextArg {
setCurrInstrument: (instrumentId: string) => void;
widgetId: Widget['id'];
getMasterInstrumentFromPublicContext: () => string | number | null | undefined;
}
interface UseChartPublicContextReturn {
issKey: Contract['issKey'] | undefined;
}
export function useChartPublicContext({
widgetId,
setCurrInstrument,
getMasterInstrumentFromPublicContext,
}: UseChartPublicContextArg): UseChartPublicContextReturn {
const publicContext = useAppSelect((state) => state.publicContext.publicContext);
const widget = useAppSelect((state) => state.widgets.widgets.find(({ id }) => id === widgetId));
const { contracts } = useContracts();
const instruments = useMemo(() => [...(contracts && filterByUniqIssKey(contracts, ['issKey']))], [contracts]);
const issKey = useMemo(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
return instrKey;
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments]);
useEffect(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
if (!fieldValue && widget?.master) {
setCurrInstrument(DEFAULT_SYMBOL);
return;
}
const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
if (instrKey) {
setCurrInstrument(instrKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments, widget?.externalProperties, setCurrInstrument]);
return {
issKey: issKey ?? undefined,
};
}
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 type { Candle } from 'moex-chart';
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 async function requestBars({
currencyPair,
interval,
periodParams,
onHistoryCallback,
ticker,
indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
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}`;
try {
const { data } = await indicativeQuotesController.getCandles({
count: periodParams.countBack,
key: currencyPair,
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> {
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий инструмент совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
try {
const { data } = await indicativeQuotesController.getCandles({
count: 1,
key: currencyPair,
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;
}
}
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,
currentInstrument,
setIsWidgetHeaderContextMenuOpen,
isWidgetHeaderContextMenuOpen,
onDropInstruments,
addInstrumentFromModal,
isOver,
} = useChartComponentFacade(props);
/* на дроп обновляем название бумаги в сторе
и делаем апдейт на бэк, чтобы сохранить изменения
при обновлении страницы */
const { dropRef } = useDropInstrument((dragData) => {
onDropInstruments(dragData.properties.issKey, true);
});
const [isOpenEmptyAction, setIsOpenEmptyAction] = useState<boolean>(false);
const openInstrumentModal = () => {
setIsOpenEmptyAction(true);
setIsWidgetHeaderContextMenuOpen(false);
};
// TODO: Зависит от сеток
const { isDraggedOver } = useContext(WidgetEnvContext);
const contractsInstrumentName = useWidgetHeaderName(currentInstrument);
const instrumentName = indicativeData
? `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`
: contractsInstrumentName;
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
fullName={currentInstrument}
indicativeData={indicativeData}
widgetId={props.widgetId}
/>
{isOpenEmptyAction && (
<InstrumentSearch
setOpen={setIsOpenEmptyAction}
isOpen={isOpenEmptyAction}
variant="single"
widgetId={props.widgetId}
// withNRD={false}
addInstruments={addInstrumentFromModal}
/>
)}
</div>
</WidgetContentWrapper>
</DNDWrapper>
);
};