Загрузка данных
diff --git a/src/modules/ntb/utils/__tests__/registerNtbBarsResolver.test.ts b/src/modules/ntb/utils/__tests__/registerNtbBarsResolver.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b6a415d34681148b9ba18910c503cccee4c4cd45
--- /dev/null
+++ b/src/modules/ntb/utils/__tests__/registerNtbBarsResolver.test.ts
@@ -0,0 +1,120 @@
+import { registerBarsResolver } from '@widgets/Chart/requestBars';
+
+import { registerNtbBarsResolver } from '../registerNtbBarsResolver';
+
+import type { NtbBarPoint } from '../registerNtbBarsResolver';
+import type { Candle } from 'moex-chart';
+
+jest.mock('@widgets/Chart/requestBars', () => ({
+ registerBarsResolver: jest.fn(),
+}));
+
+const mockRegisterBarsResolver = registerBarsResolver as jest.Mock;
+
+type BarsResolver = (args: { ticker?: string }) => Promise<Candle[]>;
+
+const registerAndGetResolver = (fetchBars: (securityId: string) => Promise<NtbBarPoint[]>): BarsResolver => {
+ registerNtbBarsResolver('TEST_BOARD', fetchBars);
+
+ return mockRegisterBarsResolver.mock.calls[0][1] as BarsResolver;
+};
+
+describe('registerNtbBarsResolver', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('регистрирует резолвер под переданный борд', () => {
+ registerNtbBarsResolver('TEST_BOARD', jest.fn());
+
+ expect(mockRegisterBarsResolver).toHaveBeenCalledWith('TEST_BOARD', expect.any(Function));
+ });
+
+ it('достаёт securityId из тикера (3-й сегмент) и передаёт его в fetchBars', async () => {
+ const fetchBars = jest.fn().mockResolvedValue([]);
+ const resolver = registerAndGetResolver(fetchBars);
+
+ await resolver({ ticker: 'PREFIX:BOARD:SEC123' });
+
+ expect(fetchBars).toHaveBeenCalledWith('SEC123');
+ });
+
+ it('маппит точки в плоские свечи: value → OHLC, date → время, volume', async () => {
+ const resolver = registerAndGetResolver(async () => [{ date: '2026-05-19', value: 40, volume: 5 }]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars).toEqual([{ time: Date.parse('2026-05-19'), open: 40, high: 40, low: 40, close: 40, volume: 5 }]);
+ });
+
+ it('подставляет volume=0, если объёма нет', async () => {
+ const resolver = registerAndGetResolver(async () => [{ date: '2026-05-19', value: 40 }]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars[0].volume).toBe(0);
+ });
+
+ it('сортирует бары по возрастанию времени', async () => {
+ const resolver = registerAndGetResolver(async () => [
+ { date: '2026-05-20', value: 2 },
+ { date: '2026-05-19', value: 1 },
+ ]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars.map((bar) => bar.time)).toEqual([Date.parse('2026-05-19'), Date.parse('2026-05-20')]);
+ });
+
+ it('схлопывает дубли по дате — побеждает последнее значение', async () => {
+ const resolver = registerAndGetResolver(async () => [
+ { date: '2026-05-19', value: 10 },
+ { date: '2026-05-19', value: 99 },
+ ]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars).toHaveLength(1);
+ expect(bars[0].close).toBe(99);
+ });
+
+ it('пропускает точки без числового value или без даты', async () => {
+ const resolver = registerAndGetResolver(async () => [
+ { date: '2026-05-19' },
+ { date: '', value: 5 },
+ { date: '2026-05-20', value: 7 },
+ ]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars).toEqual([{ time: Date.parse('2026-05-20'), open: 7, high: 7, low: 7, close: 7, volume: 0 }]);
+ });
+
+ it('парсит дату в формате dd.mm.yyyy через fallback', async () => {
+ const resolver = registerAndGetResolver(async () => [{ date: '15.03.2025', value: 3 }]);
+
+ const bars = await resolver({ ticker: 'P:B:SEC' });
+
+ expect(bars[0].time).toBe(Date.UTC(2025, 2, 15));
+ });
+
+ it('возвращает [] и не вызывает fetchBars, если securityId в тикере нет', async () => {
+ const fetchBars = jest.fn();
+ const resolver = registerAndGetResolver(fetchBars);
+
+ const bars = await resolver({ ticker: 'PREFIX:BOARD' });
+
+ expect(bars).toEqual([]);
+ expect(fetchBars).not.toHaveBeenCalled();
+ });
+
+ it('возвращает [] при undefined ticker', async () => {
+ const fetchBars = jest.fn();
+ const resolver = registerAndGetResolver(fetchBars);
+
+ const bars = await resolver({ ticker: undefined });
+
+ expect(bars).toEqual([]);
+ expect(fetchBars).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/modules/ntb/utils/registerNtbBarsResolver.ts b/src/modules/ntb/utils/registerNtbBarsResolver.ts
new file mode 100644
index 0000000000000000000000000000000000000000..338eed7cd7986e45ec528f985053e66ead3774a1
--- /dev/null
+++ b/src/modules/ntb/utils/registerNtbBarsResolver.ts
@@ -0,0 +1,56 @@
+import { registerBarsResolver } from '@widgets/Chart/requestBars';
+
+import type { Candle } from 'moex-chart';
+
+export type NtbBarPoint = {
+ date?: string;
+ value?: number;
+ volume?: number;
+};
+
+export type FetchNtbBars = (securityId: string) => Promise<NtbBarPoint[]>;
+
+const parseChartDate = (date: string): number => {
+ const parsed = Date.parse(date);
+ if (!Number.isNaN(parsed)) {
+ return parsed;
+ }
+
+ const [day, month, fullYear] = date.split('.');
+ return Date.UTC(Number(fullYear), Number(month) - 1, Number(day));
+};
+
+const mapPointsToFlatBars = (points: NtbBarPoint[]): Candle[] => {
+ const barByTime = new Map<number, Candle>();
+
+ points.forEach((point) => {
+ if (typeof point.value === 'number' && point.date) {
+ const time = parseChartDate(point.date);
+
+ barByTime.set(time, {
+ time,
+ open: point.value,
+ high: point.value,
+ low: point.value,
+ close: point.value,
+ volume: point.volume ?? 0,
+ });
+ }
+ });
+
+ return Array.from(barByTime.values()).sort((a, b) => a.time - b.time);
+};
+
+export const registerNtbBarsResolver = (board: string, fetchBars: FetchNtbBars) => {
+ registerBarsResolver(board, async ({ ticker }) => {
+ const securityId = ticker?.split(/[:.]/)[2];
+
+ if (!securityId) {
+ return [];
+ }
+
+ const points = await fetchBars(securityId);
+
+ return mapPointsToFlatBars(points);
+ });
+};
diff --git a/src/widgets/Chart/__tests__/useMoexchart.test.tsx b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
index 9063648308bac7a44182838b9f21852251111b90..2fd134c57c5a810a91e6816943804ffdecd2c233 100644
--- a/src/widgets/Chart/__tests__/useMoexchart.test.tsx
+++ b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
@@ -85,6 +85,7 @@ interface MockMoexChartConfig {
interface MockMoexChartState {
tf?: TimeframeValue;
+ initialInterval?: string;
savedData?: string;
}
@@ -113,6 +114,7 @@ describe('useMoexChart', () => {
const mockSetSnapshot = jest.fn();
const mockSetSymbol = jest.fn();
const mockGetCompareManager = jest.fn();
+ const mockSetSettings = jest.fn();
const mockDataSource = jest.fn();
const mockStartRealtime = jest.fn();
@@ -200,6 +202,7 @@ describe('useMoexChart', () => {
setSnapshot: mockSetSnapshot,
setSymbol: mockSetSymbol,
getCompareManager: mockGetCompareManager,
+ setSettings: mockSetSettings,
};
});
});
@@ -530,4 +533,55 @@ describe('useMoexChart', () => {
// Assert
expect(mockDestroy).toHaveBeenCalledTimes(1);
});
+ it('should apply initial interval on chart creation', () => {
+ // Arrange
+ mockMoexChartState = {
+ tf: Timeframes['1m'],
+ savedData: undefined,
+ initialInterval: '1Y',
+ };
+ // Act
+ render(<TestComponent symbol="MOEX:SBER" />);
+ // Assert
+ expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
+ });
+ it('should apply initial interval even when saved data exists', () => {
+ // Arrange
+ mockMoexChartState = {
+ tf: Timeframes['5m'],
+ savedData: JSON.stringify(mockSnapshot),
+ initialInterval: '1Y',
+ };
+ // Act
+ render(<TestComponent symbol="MOEX:SBER" />);
+ // Assert
+ expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
+ });
+ it('should clear initial interval in widget properties after applying', () => {
+ // Arrange
+ mockMoexChartState = {
+ tf: Timeframes['1m'],
+ savedData: undefined,
+ initialInterval: '1Y',
+ };
+ // Act
+ render(<TestComponent symbol="MOEX:SBER" />);
+ // Assert
+ expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
+ const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
+ const mockState: MockPropertiesState = {
+ moexChartState: {
+ tf: Timeframes['1m'],
+ initialInterval: '1Y',
+ },
+ };
+ updateCallback(mockState);
+ expect(mockState.moexChartState?.initialInterval).toBeUndefined();
+ });
+ it('should not apply interval when initial interval is not set', () => {
+ // Arrange
+ render(<TestComponent symbol="MOEX:SBER" />);
+ // Assert
+ expect(mockSetSettings).not.toHaveBeenCalled();
+ });
});
diff --git a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
index 74691369be3ba8f69da4aa3af6f39953120c4376..45cd44ee32113bb76ae6c8355665791c7967bf2e 100644
--- a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
+++ b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
@@ -1,6 +1,6 @@
import isNil from 'lodash/isNil';
-import { MoexChart, Timeframes } from 'moex-chart';
+import { Intervals, MoexChart, Timeframes } from 'moex-chart';
import { useEffect, useRef, useState } from 'react';
@@ -34,6 +34,7 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.tf);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
+ const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
const updateTimeframeRef = useRef<((tf: Timeframes) => void) | null>(null);
useEffect(() => {
@@ -170,6 +171,19 @@ export const useMoexChart = ({ symbol, indicativeData }: TUseMoexChartProps) =>
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
+
+ if (initialIntervalRef.current) {
+ chart.setSettings({ interval: initialIntervalRef.current });
+
+ updateProperties((state) => {
+ state.moexChartState = {
+ ...state.moexChartState,
+ initialInterval: undefined,
+ };
+ });
+ initialIntervalRef.current = undefined;
+ }
+
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
diff --git a/src/widgets/Chart/properties/types.ts b/src/widgets/Chart/properties/types.ts
index 9d0e36c23b9b8116f0a5e668ee73110cf9a1e303..1342b8cb0b3d1bdb27018fc6ab17d83fe77677dc 100644
--- a/src/widgets/Chart/properties/types.ts
+++ b/src/widgets/Chart/properties/types.ts
@@ -2,7 +2,7 @@ import { Contract } from '@modules/contracts/types';
import { ChartIndicativeData } from '../types';
-import type { Timeframes } from 'moex-chart';
+import type { Intervals, Timeframes } from 'moex-chart';
export type WidgetProperties = {
chartState: {
@@ -12,7 +12,8 @@ export type WidgetProperties = {
};
indicativeData?: ChartIndicativeData;
moexChartState?: {
- tf: Timeframes;
+ tf?: Timeframes;
+ initialInterval?: Intervals;
savedData?: string;
};
};
diff --git a/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useContextMenu.ts b/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useContextMenu.ts
index 0be9e4e504570390f3c53bd53f5ed3392b090387..067708e69160f29871ec038e3e6e8145edc79a30 100644
--- a/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useContextMenu.ts
+++ b/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useContextMenu.ts
@@ -25,6 +25,10 @@ export const useContextMenu = ({ onShowHistory }: UseContextMenuProps) => {
chartState: {
savedInstrument: instrument.issKey,
},
+ moexChartState: {
+ tf: '1d',
+ initialInterval: '1Y',
+ },
});
}
};
diff --git a/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useTableData.ts b/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useTableData.ts
index 4b82647288ba26265676da51d21f45e86c3b2d49..0674e045f158e5ebcd0085e18708b52aa22e971c 100644
--- a/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useTableData.ts
+++ b/src/widgets/ntb/Indexes/components/IndexesTable/hooks/useTableData.ts
@@ -17,6 +17,7 @@ export const useTableData = (filters?: GetCommodityIndexesRequest) => {
const { issIndexes, ntbIndexes } = useMemo(() => splitIndexesBySource(indexesData), [indexesData]);
const indexesWithContracts = useIndexesWithContracts(issIndexes);
+ const ntbIndexesWithContracts = useIndexesWithContracts(ntbIndexes);
const { quotesMap } = useQuotes(indexesWithContracts);
@@ -25,7 +26,7 @@ export const useTableData = (filters?: GetCommodityIndexesRequest) => {
[indexesWithContracts, quotesMap],
);
- const ntbData = useIndexesWithMarketData(ntbIndexes);
+ const ntbData = useIndexesWithMarketData(ntbIndexesWithContracts);
const resultData = useMemo<IndexesTableDataItem[] | undefined>(() => {
if (!indexesData) {
diff --git a/src/widgets/ntb/Indexes/const.ts b/src/widgets/ntb/Indexes/const.ts
index 58d0ea23633b69967795d9542540b5d88668fd50..a768aaf3c04df333e1b917eb13179dc1bb6165a6 100644
--- a/src/widgets/ntb/Indexes/const.ts
+++ b/src/widgets/ntb/Indexes/const.ts
@@ -3,3 +3,5 @@ export enum TableKeys {
History = 'history',
NtbHistory = 'ntbHistory',
}
+
+export const INDEXES_CHART_BOARD = 'AGRO';
diff --git a/src/widgets/ntb/Indexes/hooks/__tests__/useRegisterIndexesChartSource.test.ts b/src/widgets/ntb/Indexes/hooks/__tests__/useRegisterIndexesChartSource.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1bee9b0db98cdddd97873eac536e2dd8b1263139
--- /dev/null
+++ b/src/widgets/ntb/Indexes/hooks/__tests__/useRegisterIndexesChartSource.test.ts
@@ -0,0 +1,30 @@
+import { renderHook } from '@testing-library/react';
+
+import { registerIndexesChartSource } from '../../utils/indexesChartDataSource';
+import { useRegisterIndexesChartSource } from '../useRegisterIndexesChartSource';
+
+jest.mock('../../utils/indexesChartDataSource', () => ({
+ registerIndexesChartSource: jest.fn(),
+}));
+
+const mockRegister = registerIndexesChartSource as jest.Mock;
+
+describe('useRegisterIndexesChartSource', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('регистрирует источник баров один раз при монтировании', () => {
+ renderHook(() => useRegisterIndexesChartSource());
+
+ expect(mockRegister).toHaveBeenCalledTimes(1);
+ });
+
+ it('не регистрирует повторно при ре-рендере', () => {
+ const { rerender } = renderHook(() => useRegisterIndexesChartSource());
+
+ rerender();
+
+ expect(mockRegister).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/widgets/ntb/Indexes/hooks/useRegisterIndexesChartSource.ts b/src/widgets/ntb/Indexes/hooks/useRegisterIndexesChartSource.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6e8279c46cc842947d709d22c99a094b1a994e80
--- /dev/null
+++ b/src/widgets/ntb/Indexes/hooks/useRegisterIndexesChartSource.ts
@@ -0,0 +1,9 @@
+import { useEffect } from 'react';
+
+import { registerIndexesChartSource } from '../utils/indexesChartDataSource';
+
+export const useRegisterIndexesChartSource = () => {
+ useEffect(() => {
+ registerIndexesChartSource();
+ }, []);
+};
diff --git a/src/widgets/ntb/Indexes/utils/__tests__/indexesChartDataSource.test.ts b/src/widgets/ntb/Indexes/utils/__tests__/indexesChartDataSource.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fd986131583a1a489b841cd51f9d9ca101052f99
--- /dev/null
+++ b/src/widgets/ntb/Indexes/utils/__tests__/indexesChartDataSource.test.ts
@@ -0,0 +1,57 @@
+import { ntbIndexesController } from '@api/controllers/ntbController';
+import { registerNtbBarsResolver } from '@modules/ntb/utils/registerNtbBarsResolver';
+
+import { INDEXES_CHART_BOARD } from '../../const';
+import { registerIndexesChartSource } from '../indexesChartDataSource';
+
+import type { NtbBarPoint } from '@modules/ntb/utils/registerNtbBarsResolver';
+
+jest.mock('@modules/ntb/utils/registerNtbBarsResolver', () => ({
+ registerNtbBarsResolver: jest.fn(),
+}));
+
+jest.mock('@api/controllers/ntbController', () => ({
+ ntbIndexesController: {
+ getNtbHistory: jest.fn(),
+ },
+}));
+
+const mockRegisterNtbBarsResolver = registerNtbBarsResolver as jest.Mock;
+const mockGetNtbHistory = ntbIndexesController.getNtbHistory as jest.Mock;
+
+type FetchBars = (securityId: string) => Promise<NtbBarPoint[]>;
+
+const getFetcher = (): FetchBars => {
+ registerIndexesChartSource();
+
+ return mockRegisterNtbBarsResolver.mock.calls[0][1] as FetchBars;
+};
+
+describe('indexesChartDataSource', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('регистрирует источник баров под борд AGRO', () => {
+ registerIndexesChartSource();
+
+ expect(mockRegisterNtbBarsResolver).toHaveBeenCalledWith(INDEXES_CHART_BOARD, expect.any(Function));
+ });
+
+ it('запрашивает NTB-историю по securityId и маппит price/date/volume в точки', async () => {
+ mockGetNtbHistory.mockResolvedValue({ data: { history: [{ price: 40, date: '2026-05-19', volume: 5 }] } });
+ const fetchBars = getFetcher();
+
+ const points = await fetchBars('SEC123');
+
+ expect(mockGetNtbHistory).toHaveBeenCalledWith({ securityId: 'SEC123' });
+ expect(points).toEqual([{ date: '2026-05-19', value: 40, volume: 5 }]);
+ });
+
+ it('возвращает [] при отсутствующей истории', async () => {
+ mockGetNtbHistory.mockResolvedValue({ data: null });
+ const fetchBars = getFetcher();
+
+ expect(await fetchBars('SEC123')).toEqual([]);
+ });
+});
diff --git a/src/widgets/ntb/Indexes/utils/indexesChartDataSource.ts b/src/widgets/ntb/Indexes/utils/indexesChartDataSource.ts
new file mode 100644
index 0000000000000000000000000000000000000000..19df9c6ae13d5723e6d48cb1f547912c6ccc2eff
--- /dev/null
+++ b/src/widgets/ntb/Indexes/utils/indexesChartDataSource.ts
@@ -0,0 +1,12 @@
+import { ntbIndexesController } from '@api/controllers/ntbController';
+import { registerNtbBarsResolver } from '@modules/ntb/utils/registerNtbBarsResolver';
+
+import { INDEXES_CHART_BOARD } from '../const';
+
+export const registerIndexesChartSource = () => {
+ registerNtbBarsResolver(INDEXES_CHART_BOARD, async (securityId) => {
+ const { data } = await ntbIndexesController.getNtbHistory({ securityId });
+
+ return (data?.history ?? []).map((item) => ({ date: item.date, value: item.price, volume: item.volume }));
+ });
+};
diff --git a/src/widgets/ntb/Indexes/widget.tsx b/src/widgets/ntb/Indexes/widget.tsx
index 04cc74739124012bcf931b0e2fc26fe9b8e81e0e..d2b5421db8b816670540b912d04666a6e6c75ba8 100644
--- a/src/widgets/ntb/Indexes/widget.tsx
+++ b/src/widgets/ntb/Indexes/widget.tsx
@@ -10,6 +10,7 @@ import { HistoryTable } from './components/HistoryTable';
import { IndexesTable } from './components/IndexesTable';
import { useColumnsSettings } from './hooks/useColumnsSettings';
import { useFilterSwitch } from './hooks/useFilterSwitch';
+import { useRegisterIndexesChartSource } from './hooks/useRegisterIndexesChartSource';
import { useShowHstory } from './hooks/useShowHistory';
import styles from './Indexes.module.scss';
import { downloadHistory } from './utils/downloadHistory';
@@ -17,6 +18,8 @@ import { downloadHistory } from './utils/downloadHistory';
import type { WidgetContentBasicProps } from 'types/Widgets';
const NtbIndexes: FC<WidgetContentBasicProps> = (props) => {
+ useRegisterIndexesChartSource();
+
const filterSwitch = useFilterSwitch();
const { historySecId, historySourceId, handleShowHistory, handleReturnFromHistory } = useShowHstory();
diff --git a/src/widgets/ntb/Logistic/LogisticContextMenu/__tests__/useContextMenu.test.ts b/src/widgets/ntb/Logistic/LogisticContextMenu/__tests__/useContextMenu.test.ts
index 5b2c68104d95db544d524b744ec6c6c4441b7d26..b7ad8f6bc8b64782abb35791b8d4edca29caad41 100644
--- a/src/widgets/ntb/Logistic/LogisticContextMenu/__tests__/useContextMenu.test.ts
+++ b/src/widgets/ntb/Logistic/LogisticContextMenu/__tests__/useContextMenu.test.ts
@@ -133,7 +133,7 @@ describe('useContextMenu', () => {
expect(mockedCreateGraphicWidget).toHaveBeenCalledWith({
chartState: { savedInstrument: 'NTBVTFC.NTBVLB.111222333' },
- moexChartState: { tf: '1d' },
+ moexChartState: { tf: '1d', initialInterval: '1Y' },
});
expect(mockProps.setOpenContextMenu).toHaveBeenCalledWith(false);
});
diff --git a/src/widgets/ntb/Logistic/LogisticContextMenu/useContextMenu.ts b/src/widgets/ntb/Logistic/LogisticContextMenu/useContextMenu.ts
index 396d56419fc01b2673d6546b50936f925007c93e..6220780e91387a340f8b1c6fffa4eec4ef250c07 100644
--- a/src/widgets/ntb/Logistic/LogisticContextMenu/useContextMenu.ts
+++ b/src/widgets/ntb/Logistic/LogisticContextMenu/useContextMenu.ts
@@ -34,7 +34,7 @@ export const useContextMenu = ({
if (issKey) {
createGraphicWidget({
chartState: { savedInstrument: issKey },
- moexChartState: { tf: '1d' },
+ moexChartState: { tf: '1d', initialInterval: '1Y' },
});
}
setOpenContextMenu(false);
diff --git a/src/widgets/ntb/Logistic/utils/__tests__/logisticChartDataSource.test.ts b/src/widgets/ntb/Logistic/utils/__tests__/logisticChartDataSource.test.ts
index 54684e1bcfeef47bb67768f190909c7dacc33c6c..9297b7f3c02f27be7cc9f48e8227a44d3f05e939 100644
--- a/src/widgets/ntb/Logistic/utils/__tests__/logisticChartDataSource.test.ts
+++ b/src/widgets/ntb/Logistic/utils/__tests__/logisticChartDataSource.test.ts
@@ -1,14 +1,13 @@
import { ntbLogisticController } from '@api/controllers/ntbController';
-import { registerBarsResolver } from '@widgets/Chart/requestBars';
+import { registerNtbBarsResolver } from '@modules/ntb/utils/registerNtbBarsResolver';
import { LOGISTIC_CHART_BOARD } from '../../constants';
import { registerLogisticChartSource } from '../logisticChartDataSource';
-import type { Logistic } from '@modules/ntb/types';
-import type { Candle } from 'moex-chart';
+import type { NtbBarPoint } from '@modules/ntb/utils/registerNtbBarsResolver';
-jest.mock('@widgets/Chart/requestBars', () => ({
- registerBarsResolver: jest.fn(),
+jest.mock('@modules/ntb/utils/registerNtbBarsResolver', () => ({
+ registerNtbBarsResolver: jest.fn(),
}));
jest.mock('@api/controllers/ntbController', () => ({
@@ -17,131 +16,42 @@ jest.mock('@api/controllers/ntbController', () => ({
},
}));
-const mockRegisterBarsResolver = registerBarsResolver as jest.Mock;
+const mockRegisterNtbBarsResolver = registerNtbBarsResolver as jest.Mock;
const mockGetLogisticHistory = ntbLogisticController.getLogisticHistory as jest.Mock;
-type BarsResolver = (args: { ticker?: string }) => Promise<Candle[]>;
+type FetchBars = (securityId: string) => Promise<NtbBarPoint[]>;
-const getRegisteredResolver = (): BarsResolver => {
+const getFetcher = (): FetchBars => {
registerLogisticChartSource();
- return mockRegisterBarsResolver.mock.calls[0][1] as BarsResolver;
+ return mockRegisterNtbBarsResolver.mock.calls[0][1] as FetchBars;
};
-const createHistoryItem = (partial: Partial<Logistic>): Logistic => partial as Logistic;
-
describe('logisticChartDataSource', () => {
beforeEach(() => {
jest.clearAllMocks();
});
- it('регистрирует резолвер под борд NTBVLB', () => {
+ it('регистрирует источник баров под борд NTBVLB', () => {
registerLogisticChartSource();
- expect(mockRegisterBarsResolver).toHaveBeenCalledWith(LOGISTIC_CHART_BOARD, expect.any(Function));
+ expect(mockRegisterNtbBarsResolver).toHaveBeenCalledWith(LOGISTIC_CHART_BOARD, expect.any(Function));
});
- describe('резолвер', () => {
- it('достаёт securityId из тикера и запрашивает по нему историю', async () => {
- mockGetLogisticHistory.mockResolvedValue({ data: [] });
- const resolver = getRegisteredResolver();
-
- await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(mockGetLogisticHistory).toHaveBeenCalledWith({ securityId: 'SEC123' });
- });
-
- it('маппит историю в бары: Тариф → цена, Дата → время', async () => {
- mockGetLogisticHistory.mockResolvedValue({
- data: [createHistoryItem({ value: 40, valueDate: '2026-05-19' })],
- });
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(bars).toEqual([{ time: Date.parse('2026-05-19'), open: 40, high: 40, low: 40, close: 40, volume: 0 }]);
- });
-
- it('сортирует бары по возрастанию времени', async () => {
- mockGetLogisticHistory.mockResolvedValue({
- data: [
- createHistoryItem({ value: 2, valueDate: '2026-05-20' }),
- createHistoryItem({ value: 1, valueDate: '2026-05-19' }),
- ],
- });
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(bars.map((bar) => bar.time)).toEqual([Date.parse('2026-05-19'), Date.parse('2026-05-20')]);
- });
-
- it('схлопывает дубли по дате — побеждает последнее значение', async () => {
- mockGetLogisticHistory.mockResolvedValue({
- data: [
- createHistoryItem({ value: 10, valueDate: '2026-05-19' }),
- createHistoryItem({ value: 99, valueDate: '2026-05-19' }),
- ],
- });
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(bars).toHaveLength(1);
- expect(bars[0].close).toBe(99);
- });
+ it('запрашивает историю по securityId и маппит value/valueDate в точки', async () => {
+ mockGetLogisticHistory.mockResolvedValue({ data: [{ value: 40, valueDate: '2026-05-19' }] });
+ const fetchBars = getFetcher();
- it('пропускает записи без числового value или без даты', async () => {
- mockGetLogisticHistory.mockResolvedValue({
- data: [
- createHistoryItem({ valueDate: '2026-05-19' }),
- createHistoryItem({ value: 5, valueDate: '' }),
- createHistoryItem({ value: 7, valueDate: '2026-05-20' }),
- ],
- });
- const resolver = getRegisteredResolver();
+ const points = await fetchBars('SEC123');
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(bars).toEqual([{ time: Date.parse('2026-05-20'), open: 7, high: 7, low: 7, close: 7, volume: 0 }]);
- });
-
- it('парсит дату в формате dd.mm.yyyy через fallback', async () => {
- mockGetLogisticHistory.mockResolvedValue({
- data: [createHistoryItem({ value: 3, valueDate: '15.03.2025' })],
- });
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
-
- expect(bars[0].time).toBe(Date.UTC(2025, 2, 15));
- });
-
- it('возвращает [] и не ходит в API, если securityId в тикере нет', async () => {
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB' });
-
- expect(bars).toEqual([]);
- expect(mockGetLogisticHistory).not.toHaveBeenCalled();
- });
-
- it('возвращает [] при undefined ticker', async () => {
- const resolver = getRegisteredResolver();
-
- const bars = await resolver({ ticker: undefined });
-
- expect(bars).toEqual([]);
- expect(mockGetLogisticHistory).not.toHaveBeenCalled();
- });
-
- it('возвращает [] при пустой/отсутствующей истории', async () => {
- mockGetLogisticHistory.mockResolvedValue({ data: null });
- const resolver = getRegisteredResolver();
+ expect(mockGetLogisticHistory).toHaveBeenCalledWith({ securityId: 'SEC123' });
+ expect(points).toEqual([{ date: '2026-05-19', value: 40 }]);
+ });
- const bars = await resolver({ ticker: 'NTBVTFC:NTBVLB:SEC123' });
+ it('возвращает [] при отсутствующей истории', async () => {
+ mockGetLogisticHistory.mockResolvedValue({ data: null });
+ const fetchBars = getFetcher();
- expect(bars).toEqual([]);
- });
+ expect(await fetchBars('SEC123')).toEqual([]);
});
});
diff --git a/src/widgets/ntb/Logistic/utils/logisticChartDataSource.ts b/src/widgets/ntb/Logistic/utils/logisticChartDataSource.ts
index cc67f1cfaca04bb6b4bb9eb15dfa7481e6a4c992..caef9cc05b5661a130db13b2b4a89fdadc9f016b 100644
--- a/src/widgets/ntb/Logistic/utils/logisticChartDataSource.ts
+++ b/src/widgets/ntb/Logistic/utils/logisticChartDataSource.ts
@@ -1,45 +1,12 @@
import { ntbLogisticController } from '@api/controllers/ntbController';
-import { registerBarsResolver } from '@widgets/Chart/requestBars';
+import { registerNtbBarsResolver } from '@modules/ntb/utils/registerNtbBarsResolver';
import { LOGISTIC_CHART_BOARD } from '../constants';
-import type { Logistic } from '@modules/ntb/types';
-import type { Candle } from 'moex-chart';
-
-const parseValueDate = (valueDate: string): number => {
- const parsed = Date.parse(valueDate);
- if (!Number.isNaN(parsed)) {
- return parsed;
- }
-
- const [day, month, fullYear] = valueDate.split('.');
- return Date.UTC(Number(fullYear), Number(month) - 1, Number(day));
-};
-
-const mapHistoryToBars = (history: Logistic[]): Candle[] => {
- const valueByTime = new Map<number, number>();
-
- history.forEach((item) => {
- if (typeof item.value === 'number' && item.valueDate) {
- valueByTime.set(parseValueDate(item.valueDate), item.value);
- }
- });
-
- return Array.from(valueByTime.entries())
- .sort(([timeA], [timeB]) => timeA - timeB)
- .map(([time, value]) => ({ time, open: value, high: value, low: value, close: value, volume: 0 }));
-};
-
export const registerLogisticChartSource = () => {
- registerBarsResolver(LOGISTIC_CHART_BOARD, async ({ ticker }) => {
- const securityId = ticker?.split(/[:.]/)[2];
-
- if (!securityId) {
- return [];
- }
-
+ registerNtbBarsResolver(LOGISTIC_CHART_BOARD, async (securityId) => {
const { data } = await ntbLogisticController.getLogisticHistory({ securityId });
- return mapHistoryToBars(data ?? []);
+ return (data ?? []).map((item) => ({ date: item.valueDate, value: item.value }));
});
};