Загрузка данных


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