Загрузка данных
diff --git a/package-lock.json b/package-lock.json
index e7a4b155b0ad077262364e128dd6f86a552ff03d..643964fdcd17e227811d24c62d829fc40a71fbbb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,7 +41,7 @@
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-underline": "^1.0.1",
"meow": "^8.1.2",
- "moex-chart": "^0.1.20",
+ "moex-chart": "^0.1.21",
"rc-virtual-list": "^3.14.5",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
@@ -28350,9 +28350,9 @@
}
},
"node_modules/moex-chart": {
- "version": "0.1.20",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.20.tgz",
- "integrity": "sha1-sIjxJKdzK+78xWq7IPdxog+baqY=",
+ "version": "0.1.21",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.21.tgz",
+ "integrity": "sha1-BKbF7IhQpBDTl87XrG8rcaE8RwQ=",
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
@@ -28364,7 +28364,7 @@
"dotenv": "^16.4.7",
"exchange-elements": "^0.0.257",
"fancy-canvas": "2.1.0",
- "lightweight-charts": "5.0.8",
+ "lightweight-charts": "5.2.0",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.2",
"uuid": "^11.0.3"
@@ -28375,9 +28375,9 @@
}
},
"node_modules/moex-chart/node_modules/lightweight-charts": {
- "version": "5.0.8",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lightweight-charts/-/lightweight-charts-5.0.8.tgz",
- "integrity": "sha1-QGLromqvdz6vfVUF9+CfIq6HnYs=",
+ "version": "5.2.0",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
+ "integrity": "sha1-7z9oF5Tp2tCsExkTJZQ5rVlrz5I=",
"dependencies": {
"fancy-canvas": "2.1.0"
}
@@ -59966,9 +59966,9 @@
"dev": true
},
"moex-chart": {
- "version": "0.1.20",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.20.tgz",
- "integrity": "sha1-sIjxJKdzK+78xWq7IPdxog+baqY=",
+ "version": "0.1.21",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/moex-chart/-/moex-chart-0.1.21.tgz",
+ "integrity": "sha1-BKbF7IhQpBDTl87XrG8rcaE8RwQ=",
"requires": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
@@ -59980,16 +59980,16 @@
"dotenv": "^16.4.7",
"exchange-elements": "^0.0.257",
"fancy-canvas": "2.1.0",
- "lightweight-charts": "5.0.8",
+ "lightweight-charts": "5.2.0",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.2",
"uuid": "^11.0.3"
},
"dependencies": {
"lightweight-charts": {
- "version": "5.0.8",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lightweight-charts/-/lightweight-charts-5.0.8.tgz",
- "integrity": "sha1-QGLromqvdz6vfVUF9+CfIq6HnYs=",
+ "version": "5.2.0",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
+ "integrity": "sha1-7z9oF5Tp2tCsExkTJZQ5rVlrz5I=",
"requires": {
"fancy-canvas": "2.1.0"
}
diff --git a/package.json b/package.json
index 3a2ac88b37a2ea12a71720ad09eab50377912933..0375b87e0d3e356bb4c182ee026905bec0a7e67a 100644
--- a/package.json
+++ b/package.json
@@ -146,7 +146,7 @@
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-underline": "^1.0.1",
"meow": "^8.1.2",
- "moex-chart": "^0.1.20",
+ "moex-chart": "^0.1.21",
"rc-virtual-list": "^3.14.5",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
diff --git a/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx b/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
index 63170eaae61e87d1a79bdd5e371091c377d184c9..b9b1c2108c95f0c5403039e0ac989c95b0cdd341 100644
--- a/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
+++ b/src/components/InstrumentSearch/components/Actions/MoexChartActions.tsx
@@ -9,6 +9,7 @@ type TMoexChartActionsProps = {
selectedInstrumentRows: Contract[];
isNewScaleDisabled?: boolean;
customActionsFooterHandlers: {
+ handleAbsolute: (instrument: Contract) => void;
handlePercent: (instrument: Contract) => void;
handleNewScale: (instrument: Contract) => void;
handleNewPanel: (instrument: Contract) => void;
@@ -20,13 +21,23 @@ export const MoexChartActions = ({
isNewScaleDisabled = false,
customActionsFooterHandlers,
}: TMoexChartActionsProps) => {
- const { handlePercent, handleNewScale, handleNewPanel } = customActionsFooterHandlers;
+ const { handleAbsolute, handlePercent, handleNewScale, handleNewPanel } = customActionsFooterHandlers;
const selectedInstrument = selectedInstrumentRows[0];
const isButtonDisabled = !selectedInstrument?.issKey;
return (
<div className={styles.actionWrapper}>
+ <Button
+ text="Абсолютная шкала"
+ onClick={() => {
+ if (selectedInstrument?.issKey) {
+ handleAbsolute(selectedInstrument);
+ }
+ }}
+ disabled={isButtonDisabled}
+ variant="outlined-secondary"
+ />
<Button
text="%"
onClick={() => {
diff --git a/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx b/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
index 05e49ded0eecb603649610a4290f6c479d94eede..ae0d6277d3239e7898268d2558d0f84c032f4ca7 100644
--- a/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
+++ b/src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Contract } from '@modules/contracts';
@@ -8,6 +8,7 @@ import { MoexChartActions } from '../MoexChartActions';
describe('MoexChartActions', () => {
const mockHandlers = {
+ handleAbsolute: jest.fn(),
handlePercent: jest.fn(),
handleNewScale: jest.fn(),
handleNewPanel: jest.fn(),
@@ -15,122 +16,72 @@ describe('MoexChartActions', () => {
const mockContract = {
issKey: 'RU000A0JQ0Y0',
- };
+ } as Contract;
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('should render correctly with no selected instruments', () => {
- // Arrange & Act
+ const renderComponent = (selectedInstrumentRows: Contract[] = [], isNewScaleDisabled = false) =>
render(
<MoexChartActions
- selectedInstrumentRows={[]}
+ selectedInstrumentRows={selectedInstrumentRows}
customActionsFooterHandlers={mockHandlers}
+ isNewScaleDisabled={isNewScaleDisabled}
/>,
);
- // Assert
- expect(screen.getByText('%')).toBeInTheDocument();
- expect(screen.getByText('Новая шкала')).toBeInTheDocument();
- expect(screen.getByText('Новая панель')).toBeInTheDocument();
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('should render correctly with selected instruments', () => {
- // Arrange
- const selectedInstruments = [mockContract];
+ it('should render all action buttons', () => {
+ renderComponent();
- // Act
- render(
- <MoexChartActions
- selectedInstrumentRows={selectedInstruments as Contract[]}
- customActionsFooterHandlers={mockHandlers}
- />,
- );
-
- // Assert
+ expect(screen.getByText('Абсолютная шкала')).toBeInTheDocument();
expect(screen.getByText('%')).toBeInTheDocument();
expect(screen.getByText('Новая шкала')).toBeInTheDocument();
expect(screen.getByText('Новая панель')).toBeInTheDocument();
-
- // All buttons should be enabled
- const percentButton = screen.getByText('%');
- const newScaleButton = screen.getByText('Новая шкала');
- const newPanelButton = screen.getByText('Новая панель');
-
- expect(percentButton).not.toBeDisabled();
- expect(newScaleButton).not.toBeDisabled();
- expect(newPanelButton).not.toBeDisabled();
});
- it('should call handlePercent when percent button is clicked and instrument is selected', () => {
- // Arrange
- const selectedInstruments = [mockContract];
-
- render(
- <MoexChartActions
- selectedInstrumentRows={selectedInstruments as Contract[]}
- customActionsFooterHandlers={mockHandlers}
- />,
- );
-
- const percentButton = screen.getByText('%');
+ it('should disable all action buttons when instrument is not selected', () => {
+ renderComponent();
- expect(percentButton).not.toBeDisabled();
-
- // Act
- percentButton.click();
-
- // Assert
- expect(mockHandlers.handlePercent).toHaveBeenCalledWith(mockContract);
+ expect(screen.getByText('Абсолютная шкала')).toBeDisabled();
+ expect(screen.getByText('%')).toBeDisabled();
+ expect(screen.getByText('Новая шкала')).toBeDisabled();
+ expect(screen.getByText('Новая панель')).toBeDisabled();
});
- it('should call handleNewScale when new scale button is clicked and instrument is selected', () => {
- // Arrange
- const selectedInstruments = [mockContract];
+ it('should enable all action buttons when instrument is selected', () => {
+ renderComponent([mockContract]);
- render(
- <MoexChartActions
- selectedInstrumentRows={selectedInstruments as Contract[]}
- customActionsFooterHandlers={mockHandlers}
- />,
- );
-
- const newScaleButton = screen.getByText('Новая шкала');
-
- expect(newScaleButton).not.toBeDisabled();
-
- // Act
- newScaleButton.click();
-
- // Assert
- expect(mockHandlers.handleNewScale).toHaveBeenCalledWith(mockContract);
+ expect(screen.getByText('Абсолютная шкала')).not.toBeDisabled();
+ expect(screen.getByText('%')).not.toBeDisabled();
+ expect(screen.getByText('Новая шкала')).not.toBeDisabled();
+ expect(screen.getByText('Новая панель')).not.toBeDisabled();
});
- it('should call handleNewPanel when new panel button is clicked and instrument is selected', () => {
- // Arrange
- const selectedInstruments = [mockContract];
+ it('should disable only new scale button when new scale is unavailable', () => {
+ renderComponent([mockContract], true);
- render(
- <MoexChartActions
- selectedInstrumentRows={selectedInstruments as Contract[]}
- customActionsFooterHandlers={mockHandlers}
- />,
- );
-
- const newPanelButton = screen.getByText('Новая панель');
+ expect(screen.getByText('Абсолютная шкала')).not.toBeDisabled();
+ expect(screen.getByText('%')).not.toBeDisabled();
+ expect(screen.getByText('Новая шкала')).toBeDisabled();
+ expect(screen.getByText('Новая панель')).not.toBeDisabled();
+ });
- expect(newPanelButton).not.toBeDisabled();
+ it.each([
+ ['Абсолютная шкала', 'handleAbsolute'],
+ ['%', 'handlePercent'],
+ ['Новая шкала', 'handleNewScale'],
+ ['Новая панель', 'handleNewPanel'],
+ ] as const)('should call %s handler when button is clicked', (buttonText, handlerName) => {
+ renderComponent([mockContract]);
- // Act
- newPanelButton.click();
+ fireEvent.click(screen.getByText(buttonText));
- // Assert
- expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith(mockContract);
+ expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
+ expect(mockHandlers[handlerName]).toHaveBeenCalledWith(mockContract);
});
- it('should handle multiple selected instruments (only first is used)', () => {
- // Arrange
+ it('should use first instrument when multiple instruments are selected', () => {
const multipleContracts = [
{
issKey: 'RU000A0JQ0Y0',
@@ -138,30 +89,16 @@ describe('MoexChartActions', () => {
{
issKey: 'RU000A0JQ0Y1',
},
- ];
-
- render(
- <MoexChartActions
- selectedInstrumentRows={multipleContracts as Contract[]}
- customActionsFooterHandlers={mockHandlers}
- />,
- );
-
- const percentButton = screen.getByText('%');
- const newScaleButton = screen.getByText('Новая шкала');
- const newPanelButton = screen.getByText('Новая панель');
+ ] as Contract[];
- expect(percentButton).not.toBeDisabled();
- expect(newScaleButton).not.toBeDisabled();
- expect(newPanelButton).not.toBeDisabled();
+ renderComponent(multipleContracts);
- // Act
- percentButton.click();
- newScaleButton.click();
- newPanelButton.click();
+ fireEvent.click(screen.getByText('Абсолютная шкала'));
+ fireEvent.click(screen.getByText('%'));
+ fireEvent.click(screen.getByText('Новая шкала'));
+ fireEvent.click(screen.getByText('Новая панель'));
- // Assert
- // Verify that handlers are called with the first selected instrument
+ expect(mockHandlers.handleAbsolute).toHaveBeenCalledWith(multipleContracts[0]);
expect(mockHandlers.handlePercent).toHaveBeenCalledWith(multipleContracts[0]);
expect(mockHandlers.handleNewScale).toHaveBeenCalledWith(multipleContracts[0]);
expect(mockHandlers.handleNewPanel).toHaveBeenCalledWith(multipleContracts[0]);
diff --git a/src/components/InstrumentSearch/types/hooks.ts b/src/components/InstrumentSearch/types/hooks.ts
index d82fa0da32ca962cb03a423950e15f28f3b7239e..aa7d44a7ceb4efd3eb4f2c64608f7db2634ae8ab 100644
--- a/src/components/InstrumentSearch/types/hooks.ts
+++ b/src/components/InstrumentSearch/types/hooks.ts
@@ -74,6 +74,7 @@ export type InstrumentSearchType = {
* TODO: убрать, когда разработаем свою модалку для moex_chart
*/
customActionsFooterHandlers?: {
+ handleAbsolute: (instrument: Contract) => void;
handlePercent: (instrument: Contract) => void;
handleNewScale: (instrument: Contract) => void;
handleNewPanel: (instrument: Contract) => void;
diff --git a/src/widgets/Chart/__tests__/CompareModal.test.tsx b/src/widgets/Chart/__tests__/CompareModal.test.tsx
index 893eb8c5d7ab875f90cb5905f7778544e5bfd029..f8a57c7cd553e3b643e9ebd0847714cb6dab9681 100644
--- a/src/widgets/Chart/__tests__/CompareModal.test.tsx
+++ b/src/widgets/Chart/__tests__/CompareModal.test.tsx
@@ -14,6 +14,7 @@ import type { MutableRefObject } from 'react';
jest.mock('moex-chart', () => ({
__esModule: true,
CompareMode: {
+ Absolute: 'ABS',
Percentage: 'PCT',
NewScale: 'SCALE',
NewPane: 'PANE',
@@ -25,6 +26,7 @@ jest.mock('@components/InstrumentSearch', () => ({
}));
interface CompareActions {
+ handleAbsolute: (instrument: Contract) => void;
handlePercent: (instrument: Contract) => void;
handleNewScale: (instrument: Contract) => void;
handleNewPanel: (instrument: Contract) => void;
@@ -101,21 +103,19 @@ describe('CompareModal', () => {
});
it('should pass modal properties and current scale state to instrument search', () => {
- // Arrange
mockIsNewScaleDisabled.mockReturnValue(true);
- // Act
renderComponent();
const instrumentSearchProps = getInstrumentSearchProps();
- // Assert
expect(instrumentSearchProps.widgetId).toBe(42);
expect(instrumentSearchProps.variant).toBe('single');
expect(instrumentSearchProps.isOpen).toBe(true);
expect(instrumentSearchProps.setOpen).toBe(mockSetOpen);
expect(instrumentSearchProps.isNewScaleDisabled).toBe(true);
expect(instrumentSearchProps.customActionsFooterHandlers).toEqual({
+ handleAbsolute: expect.any(Function),
handlePercent: expect.any(Function),
handleNewScale: expect.any(Function),
handleNewPanel: expect.any(Function),
@@ -123,68 +123,55 @@ describe('CompareModal', () => {
});
it('should subscribe to new scale disabled state', () => {
- // Arrange & Act
renderComponent();
- // Assert
expect(mockIsNewScaleDisabled).toHaveBeenCalledTimes(1);
expect(mockIsNewScaleDisabledObservable).toHaveBeenCalledTimes(1);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
});
it('should update new scale disabled state from manager observable', () => {
- // Arrange
renderComponent();
- // Act
act(() => {
newScaleDisabledListener?.(true);
});
- // Assert
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(true);
});
it('should unsubscribe from manager observable on unmount', () => {
- // Arrange
const { unmount } = renderComponent();
- // Act
unmount();
- // Assert
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
});
it('should not subscribe when modal is closed', () => {
- // Arrange & Act
renderComponent(false);
- // Assert
expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
});
it('should not subscribe when compare manager is unavailable', () => {
- // Arrange
compareManagerRef.current = null;
- // Act
renderComponent();
- // Assert
expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
});
it.each([
+ ['handleAbsolute', CompareMode.Absolute],
['handlePercent', CompareMode.Percentage],
['handleNewScale', CompareMode.NewScale],
['handleNewPanel', CompareMode.NewPane],
] as const)('should add compare instrument using %s action', (handlerName, mode) => {
- // Arrange
renderComponent();
const instrument = {
@@ -195,10 +182,8 @@ describe('CompareModal', () => {
const handlers = getInstrumentSearchProps().customActionsFooterHandlers;
- // Act
handlers[handlerName](instrument);
- // Assert
expect(mockSetSymbolMode).toHaveBeenCalledTimes(1);
expect(mockSetSymbolMode).toHaveBeenCalledWith(
'Line',
@@ -212,7 +197,6 @@ describe('CompareModal', () => {
});
it('should delegate missing symbol metadata fallback to moex chart', () => {
- // Arrange
renderComponent();
const instrument = {
@@ -221,10 +205,8 @@ describe('CompareModal', () => {
symbol: '',
} as Contract;
- // Act
getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
- // Assert
expect(mockSetSymbolMode).toHaveBeenCalledWith(
'Line',
{
@@ -237,7 +219,6 @@ describe('CompareModal', () => {
});
it('should not add compare instrument without issKey', () => {
- // Arrange
renderComponent();
const instrument = {
@@ -246,10 +227,8 @@ describe('CompareModal', () => {
symbol: 'SBER',
} as Contract;
- // Act
- getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
+ getInstrumentSearchProps().customActionsFooterHandlers.handleAbsolute(instrument);
- // Assert
expect(mockSetSymbolMode).not.toHaveBeenCalled();
});
});
diff --git a/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx b/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
index 2ffba9e87eeba29c552cc04b9dba1eca673f6865..31f9c4aca9323ba620063f6c97eb258d3dcac0c5 100644
--- a/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
+++ b/src/widgets/Chart/components/MoexChart/components/CompareModal.tsx
@@ -48,6 +48,10 @@ export const CompareModal = ({ compareManager, widgetId, isOpen, setOpen }: Comp
);
};
+ const handleAbsolute = (instrument: Contract): void => {
+ setCompareMode(instrument, CompareMode.Absolute);
+ };
+
const handlePercent = (instrument: Contract): void => {
setCompareMode(instrument, CompareMode.Percentage);
};
@@ -73,6 +77,7 @@ export const CompareModal = ({ compareManager, widgetId, isOpen, setOpen }: Comp
}}
isNewScaleDisabled={isNewScaleDisabled}
customActionsFooterHandlers={{
+ handleAbsolute,
handlePercent,
handleNewScale,
handleNewPanel,
diff --git a/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts b/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
index 902b524cc913b13285ced84ea657423516db15bd..a56e2587ebc43c0ba0bef110320e21f412cb7f27 100644
--- a/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
+++ b/src/widgets/Chart/components/MoexChart/dataSourceProvide.ts
@@ -13,6 +13,12 @@ import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
+interface RealtimeState {
+ previousTime: number | null;
+ candles: Candle[];
+ sessionStart: number | null;
+}
+
function getRequestSymbol(symbolRaw?: string): string | undefined {
const symbol = String(symbolRaw ?? '').trim();
@@ -29,8 +35,15 @@ function getTimeframeSeconds(timeframe: Timeframes): number {
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
}
-function getBucketStart(time: number, sessionStart: number, timeframeSeconds: number): number {
- return sessionStart + Math.floor((time - sessionStart) / timeframeSeconds) * timeframeSeconds;
+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 aggregateCandles(candles: Candle[], time: number): Candle | undefined {
@@ -41,26 +54,160 @@ function aggregateCandles(candles: Candle[], time: number): Candle | undefined {
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: Math.max(...candles.map(({ high }) => high)),
- low: Math.min(...candles.map(({ low }) => low)),
+ high,
+ low,
close: lastCandle.close,
- volume: candles.reduce((total, candle) => total + (candle.volume ?? 0), 0),
+ volume,
};
}
-// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
-// и не плодить несколько подключений на одни символа
-class DataSourceProvider {
- private prevRealtimeDataArr: 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) {
+ const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
+
+ if (aggregatedCandle) {
+ result.push(aggregatedCandle);
+ }
+
+ sessionStart = candle.time;
+ bucketStart = candle.time;
+ candleGroup = [candle];
+ } else {
+ const currentBucketStart =
+ sessionStart + Math.floor((candle.time - sessionStart) / timeframeSeconds) * timeframeSeconds;
+
+ if (currentBucketStart !== bucketStart) {
+ const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
+
+ if (aggregatedCandle) {
+ result.push(aggregatedCandle);
+ }
+
+ bucketStart = currentBucketStart;
+ candleGroup = [];
+ }
+
+ candleGroup.push(candle);
+ }
+
+ previousTime = candle.time;
+ });
- private prevRealtimeData: Candle | undefined;
+ const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
- private realtimeShouldBeConvoluted = false;
+ if (aggregatedCandle) {
+ result.push(aggregatedCandle);
+ }
+
+ 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,
+ };
+}
- private realtimeSessionStart: number | null = null;
+// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
+// и не плодить несколько подключений на одни символа
+class DataSourceProvider {
+ private readonly realtimeStates = new Map<string, RealtimeState>();
private realtimeTimer: ReturnType<typeof setInterval> | null = null;
@@ -91,27 +238,31 @@ class DataSourceProvider {
indicativeData,
});
- if (data.length === 0) {
+ const validData = data.filter(isValidCandle);
+
+ if (validData.length === 0) {
return null;
}
- const issTimeframe = moexChartToIssTimeframe(timeframe);
-
- if (issTimeframe === timeframe) {
- this.realtimeShouldBeConvoluted = false;
-
+ if (moexChartToIssTimeframe(timeframe) === timeframe) {
if (!until) {
- this.prevRealtimeData = data[data.length - 1];
- this.prevRealtimeDataArr = [];
- this.realtimeSessionStart = null;
+ this.realtimeStates.set(symbol, {
+ previousTime: validData[validData.length - 1]?.time ?? null,
+ candles: [],
+ sessionStart: null,
+ });
}
- return data;
+ return validData;
}
- this.realtimeShouldBeConvoluted = true;
+ const { candles, state } = timeframeConvolution(validData, timeframe);
- return this.timeframeConvolution(data, timeframe, !until);
+ if (!until) {
+ this.realtimeStates.set(symbol, state);
+ }
+
+ return candles;
};
public startRealtime({
@@ -134,7 +285,7 @@ class DataSourceProvider {
this.realtimeTimer = setInterval(() => {
const timeframe = getTimeframe();
- Promise.all(
+ Promise.allSettled(
getSymbols().map((symbolId) => this.updateRealtimeSymbol(symbolId, timeframe, update, indicativeData)),
);
}, periodMs);
@@ -167,133 +318,34 @@ class DataSourceProvider {
indicativeData,
});
- if (!data) {
+ if (!data || !isValidCandle(data)) {
return;
}
- if (this.prevRealtimeData && JSON.stringify(data) === JSON.stringify(this.prevRealtimeData)) {
- return;
- }
+ if (moexChartToIssTimeframe(timeframe) === timeframe) {
+ this.realtimeStates.set(symbol, {
+ previousTime: data.time,
+ candles: [],
+ sessionStart: null,
+ });
- if (!this.realtimeShouldBeConvoluted) {
- this.prevRealtimeData = data;
update(symbol, data);
-
return;
}
- this.realtimeConvolution(timeframe, data, (candle) => {
- update(symbol, candle);
- });
- }
-
- private timeframeConvolution(data: Candle[], requestedTimeframe: Timeframes, syncRealtime: boolean): Candle[] {
- const timeframeSeconds = getTimeframeSeconds(requestedTimeframe);
- const sortedData = [...data].sort((first, second) => first.time - second.time);
- const firstCandle = sortedData[0];
-
- if (!firstCandle) {
- return [];
- }
-
- const result: Candle[] = [];
-
- let sessionStart = firstCandle.time;
- let bucketStart = sessionStart;
- let candleGroup: Candle[] = [];
-
- sortedData.forEach((candle, index) => {
- const previousCandle = sortedData[index - 1];
- const isNewSession = previousCandle && candle.time - previousCandle.time > timeframeSeconds;
-
- if (isNewSession) {
- const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
-
- if (aggregatedCandle) {
- result.push(aggregatedCandle);
- }
-
- sessionStart = candle.time;
- bucketStart = candle.time;
- candleGroup = [candle];
-
- return;
- }
-
- const currentBucketStart = getBucketStart(candle.time, sessionStart, timeframeSeconds);
-
- if (currentBucketStart !== bucketStart) {
- const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
-
- if (aggregatedCandle) {
- result.push(aggregatedCandle);
- }
-
- bucketStart = currentBucketStart;
- candleGroup = [];
- }
-
- candleGroup.push(candle);
- });
-
- const aggregatedCandle = aggregateCandles(candleGroup, bucketStart);
-
- if (aggregatedCandle) {
- result.push(aggregatedCandle);
- }
-
- if (syncRealtime) {
- this.realtimeSessionStart = sessionStart;
- this.prevRealtimeDataArr = [...candleGroup];
- this.prevRealtimeData = sortedData[sortedData.length - 1];
- }
-
- return result;
- }
-
- private realtimeConvolution(timeframe: Timeframes, data: Candle, update: (candle: Candle) => void): void {
- const timeframeSeconds = getTimeframeSeconds(timeframe);
- const sessionStart = this.updateRealtimeBuffer(data, timeframeSeconds);
+ const currentState = this.realtimeStates.get(symbol) ?? {
+ previousTime: null,
+ candles: [],
+ sessionStart: null,
+ };
- this.prevRealtimeData = data;
+ const { candle, state } = realtimeConvolution(timeframe, data, currentState);
- const bucketStart = getBucketStart(data.time, sessionStart, timeframeSeconds);
- const candle = aggregateCandles(this.prevRealtimeDataArr, bucketStart);
+ this.realtimeStates.set(symbol, state);
if (candle) {
- update(candle);
- }
- }
-
- private updateRealtimeBuffer(data: Candle, timeframeSeconds: number): number {
- const { prevRealtimeData } = this;
- const sessionStart = this.realtimeSessionStart;
-
- if (!prevRealtimeData || sessionStart === null || data.time - prevRealtimeData.time > timeframeSeconds) {
- this.realtimeSessionStart = data.time;
- this.prevRealtimeDataArr = [data];
-
- return data.time;
- }
-
- const previousBucketStart = getBucketStart(prevRealtimeData.time, sessionStart, timeframeSeconds);
- const currentBucketStart = getBucketStart(data.time, sessionStart, timeframeSeconds);
-
- if (currentBucketStart !== previousBucketStart) {
- this.prevRealtimeDataArr = [data];
-
- return sessionStart;
- }
-
- const candleIndex = this.prevRealtimeDataArr.findIndex((candle) => candle.time === data.time);
-
- if (candleIndex === -1) {
- this.prevRealtimeDataArr.push(data);
- } else {
- this.prevRealtimeDataArr[candleIndex] = data;
+ update(symbol, candle);
}
-
- return sessionStart;
}
}
Вот такая ошибка, а также понижение complexity в dataSourceProvider с 17 до 15 в моментах:
Summary of all failing tests
FAIL src/widgets/Chart/__tests__/dataSourceProvide.test.ts
● DataSourceProvider › should not update duplicated realtime candle twice
expect(jest.fn()).toHaveBeenCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 2
747 |
748 | expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(2);
> 749 | expect(mockUpdate).toHaveBeenCalledTimes(1);
| ^
750 | expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', mockBar);
751 | });
752 |
at Object.<anonymous> (src/widgets/Chart/__tests__/dataSourceProvide.test.ts:749:24)
FAIL src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx
● MoexChartActions › should disable all action buttons when instrument is not selected
expect(element).toBeDisabled()
Received element is not disabled:
<span />
44 | renderComponent();
45 |
> 46 | expect(screen.getByText('Абсолютная шкала')).toBeDisabled();
| ^
47 | expect(screen.getByText('%')).toBeDisabled();
48 | expect(screen.getByText('Новая шкала')).toBeDisabled();
49 | expect(screen.getByText('Новая панель')).toBeDisabled();
at Object.<anonymous> (src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx:46:50)
at runMicrotasks (<anonymous>)
● MoexChartActions › should disable only new scale button when new scale is unavailable
expect(element).toBeDisabled()
Received element is not disabled:
<span />
64 | expect(screen.getByText('Абсолютная шкала')).not.toBeDisabled();
65 | expect(screen.getByText('%')).not.toBeDisabled();
> 66 | expect(screen.getByText('Новая шкала')).toBeDisabled();
| ^
67 | expect(screen.getByText('Новая панель')).not.toBeDisabled();
68 | });
69 |
at Object.<anonymous> (src/components/InstrumentSearch/components/Actions/__tests__/MoexChartActions.test.tsx:66:45)
at runMicrotasks (<anonymous>)
Test Suites: 2 failed, 730 passed, 732 total
Tests: 3 failed, 7299 passed, 7302 total
Snapshots: 2 passed, 2 total
Time: 633.361 s
Ran all test suites.
Uploading artifacts for failed job
00:01
Uploading artifacts...
coverage/lcov.info: found 1 matching artifact files and directories
WARNING: test-report.xml: no matching files. Ensure that the artifact path is relative to the working directory (/builds/info-trade-terminal/moex-terminal-front)
Uploading artifacts as "archive" to coordinator... 201 Created correlation_id=01M1E8H1K9KRS8C5Z1B4BDD72X id=17448052 status=201 token=67_YuGE3y