Загрузка данных
import { Bar } from '@modules/charting_library';
import { Candle } from 'types/Candles';
// Возвращает время завершения свечи
const getCandleEndTime = (time: string, interval: number | string) => {
const parsedDate = Date.parse(`${time}Z`);
if ([1, 'MINUTE_1'].includes(interval)) {
// 1 минута
const ms = 60 * 1000;
return Math.ceil(parsedDate / ms) * ms - 1;
}
if ([10, 'MINUTE_10'].includes(interval)) {
// 10 минут
const ms = 10 * 60 * 1000;
return Math.ceil(parsedDate / ms) * ms - 1;
}
if ([60, 'HOUR_1'].includes(interval)) {
// 60 минут
const ms = 60 * 60 * 1000;
return Math.ceil(parsedDate / ms) * ms - 1;
}
if ([24, 'DAY_1'].includes(interval)) {
// 24 часа
const ms = 24 * 60 * 60 * 1000;
return Math.ceil(parsedDate / ms) * ms - 1;
}
return parsedDate;
};
export const candleToBar = ({ open, close, high, low, ticker, volume, end, interval }: Candle): Bar => ({
open,
close,
high,
low,
// TODO временное решение по просьбе PO обнулять volume для прайм инструментов на графике
// в котировках volume и value значения всегда null
volume: volume || 0,
time: getCandleEndTime(end, interval),
});
import dayjs, { ManipulateType } from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { getStartTime, parseTimeframe } from 'moex-chart';
import { Bar } from '@modules/charting_library';
import {
getISSOddTimeframePart,
moexChartTimeConverter,
moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';
import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';
import type { Candle, Timeframes } from 'moex-chart';
dayjs.extend(duration);
function normalizeSymbol(symbolRaw?: string): string {
return String(symbolRaw ?? '')
.trim()
.toUpperCase();
}
const proceedConvolution = ({
data,
moexCandle,
requestedTimeframe,
dayjsUnit,
iterationCounter,
}: {
data: Candle[];
moexCandle: number;
requestedTimeframe: Timeframes;
dayjsUnit: ManipulateType;
iterationCounter: number;
}) => {
const res: Candle[] = [];
let i = 0;
while (i < data.length - 1) {
let nextRes: Candle | undefined;
const startTime = getStartTime(requestedTimeframe, dayjs(data[i].time).add(moexCandle, dayjsUnit).unix() * 1000);
let j = 0;
while (j < iterationCounter) {
if (!nextRes) {
nextRes = data[i + j];
} else {
nextRes = {
...nextRes,
open: nextRes.open,
high: Math.max(data[i + j]!.high, nextRes.high),
low: Math.min(data[i + j]!.low, nextRes.low),
close: data[i + j].close,
volume: (nextRes!.volume ?? 0) + (data[i + j]!.volume ?? 0),
};
}
if (i + j + 1 === data.length || data[i + j].time > startTime * 1000) {
break;
}
j += 1;
}
res.push(nextRes!);
i += j;
}
return res;
};
const timeframeConvolution = (data: Candle[], requestedTimeframe: Timeframes) => {
const issTF = moexChartToIssTimeframe(requestedTimeframe);
if (issTF === requestedTimeframe) {
return data;
}
const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(requestedTimeframe);
const dataStartTime = getStartTime(requestedTimeframe, dayjs(data[0].time).add(moexCandle, dayjsUnit).unix() * 1000);
const { value, unit } = getISSOddTimeframePart(issTF);
let k = 0;
while (dataStartTime !== dayjs(data[k].time).subtract(value, unit).unix() && k < 60) {
k += 1;
}
if (k > 59) {
return [];
}
const { candleWidth: issCandle } = parseTimeframe(issTF);
const iterationCounter = moexCandle / issCandle;
const dataCropped = data.slice(k);
return proceedConvolution({
data: dataCropped,
moexCandle,
requestedTimeframe,
dayjsUnit,
iterationCounter,
});
};
// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
private prevRealtimeDataArr: Bar[] = [];
private prevRealtimeData: Bar | undefined = undefined;
private realtimeShouldBeConvoluted = false;
private realtimeTimer: ReturnType<typeof setInterval> | null = null;
public getDataSource = (indicativeData?: ChartIndicativeData | undefined, cb?: (tf: Timeframes) => void) => {
const res = async (timeframe: Timeframes, symbol: string, until?: Candle) => {
cb?.(timeframe);
const interval = moexChartTimeConverter(timeframe);
const date = until?.time || Math.round(Date.now() / 1000);
const data = await requestBars({
currencyPair: symbol.replaceAll(':', '.'),
interval,
periodParams: {
firstDataRequest: true,
to: date,
from: Date.now(),
countBack: 2000,
},
ticker: symbol,
indicativeData,
});
if (data.length === 0) {
return null;
}
const issTF = moexChartToIssTimeframe(timeframe);
if (issTF === timeframe) {
this.realtimeShouldBeConvoluted = false;
return data;
}
this.realtimeShouldBeConvoluted = true;
return timeframeConvolution(data, timeframe);
};
return res;
};
public startRealtime({
getSymbols,
getTimeframe,
update,
periodMs = 5000,
indicativeData,
}: {
getSymbols: () => string[];
getTimeframe: () => Timeframes;
update: (symbol: string, candle: Candle) => void;
periodMs?: number;
indicativeData?: ChartIndicativeData | undefined;
}) {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = setInterval(async () => {
const timeframe = getTimeframe();
const symbols = getSymbols();
symbols.forEach(async (value) => {
const symbol = normalizeSymbol(value);
const data = await requestRealtimeBars({
currencyPair: symbol.replaceAll(':', '.'),
interval: moexChartTimeConverter(timeframe),
ticker: symbol,
indicativeData,
});
if (data) {
if (!this.prevRealtimeData) {
this.prevRealtimeData = data;
this.prevRealtimeDataArr = [data];
update(symbol, data);
} else if (this.prevRealtimeData && JSON.stringify(data) !== JSON.stringify(this.prevRealtimeData)) {
this.realtimeConvolution(timeframe, data, (candle: Candle) => update(symbol, candle));
}
}
});
}, periodMs);
return () => {
if (this.realtimeTimer) {
clearInterval(this.realtimeTimer);
}
this.realtimeTimer = null;
};
}
private realtimeConvolution(timeframe: Timeframes, data: Bar, update: (candle: Candle) => void) {
let candle = data;
if (this.realtimeShouldBeConvoluted) {
const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(timeframe);
const dataStartTimeFromPrevData = getStartTime(
timeframe,
dayjs(this.prevRealtimeData!.time).add(moexCandle, dayjsUnit).unix() * 1000,
);
const dataStartTime = getStartTime(timeframe, dayjs(data.time).add(moexCandle, dayjsUnit).unix() * 1000);
const isNextTFStep = dataStartTime !== dataStartTimeFromPrevData;
if (isNextTFStep) {
this.prevRealtimeDataArr = [data];
} else {
const isUpdateForNewBarWithinTF = this.prevRealtimeData!.time !== data.time;
if (isUpdateForNewBarWithinTF) {
this.prevRealtimeDataArr.push(data);
} else {
this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1] = data;
}
candle = {
time: this.prevRealtimeDataArr[0].time,
open: this.prevRealtimeDataArr[0].open,
high: Math.max(...this.prevRealtimeDataArr.map((bar: Bar) => bar.high)),
low: Math.min(...this.prevRealtimeDataArr.map((bar: Bar) => bar.low)),
close: this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1].close,
volume: this.prevRealtimeDataArr.reduce((acc, curr) => acc + (curr.volume ?? 0), 0),
};
}
}
this.prevRealtimeData = data;
update(candle);
}
}
export { DataSourceProvider };
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { HistoryCallback, PeriodParams, SubscribeBarsCallback } from '@modules/charting_library';
import { candleToBar } from '@utils/candleToBar';
import { ChartIndicativeData } from './types';
import { isIndicativeTicker } from './utils/isIndicativeTicker';
dayjs.extend(utc);
interface RequestBarsArgs {
currencyPair: string;
interval: string;
periodParams: PeriodParams;
onHistoryCallback?: HistoryCallback;
ticker?: string;
indicativeData?: ChartIndicativeData;
}
interface RequestRealtimeBarsArgs {
currencyPair: string;
interval: string;
ticker?: string;
indicativeData?: ChartIndicativeData;
onRealtimeCallback?: SubscribeBarsCallback;
}
export async function requestBars({
currencyPair,
interval,
periodParams,
onHistoryCallback,
ticker,
indicativeData,
}: RequestBarsArgs) {
const date = new Date(periodParams.to * 1000);
const year = date.getUTCFullYear();
const month = `0${date.getUTCMonth() + 1}`.slice(-2);
const day = `0${date.getUTCDate()}`.slice(-2);
const hours = `0${date.getUTCHours()}`.slice(-2);
const minutes = `0${date.getUTCMinutes()}`.slice(-2);
const seconds = `0${date.getUTCSeconds()}`.slice(-2);
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий symbolInfo из трединг вью совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
try {
const { data } = await indicativeQuotesController.getCandles({
count: periodParams.countBack,
key: currencyPair,
date: dateStr,
interval,
});
const candles = data.indicativeCandles.reverse().map(candleToBar);
onHistoryCallback?.(candles, { noData: candles.length === 0 });
return candles;
} catch (e) {
onHistoryCallback?.([], { noData: true });
return [];
}
} else {
const dateStr = `${year}-${month}-${day}%20${hours}:${minutes}:${seconds}`;
try {
const { data } = await api.getBars({
currencyPair,
date: dateStr,
interval,
count: periodParams.countBack,
ticker,
});
const bars = data.reverse().map(candleToBar);
onHistoryCallback?.(bars, { noData: bars.length === 0 });
return bars;
} catch (e) {
onHistoryCallback?.([], { noData: true });
return [];
}
}
}
export const requestRealtimeBars = async ({
currencyPair,
interval,
ticker,
onRealtimeCallback,
indicativeData,
}: RequestRealtimeBarsArgs) => {
const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);
// если при инициализации графика были данные indicativeData и текущий symbolInfo из трединг вью совпадает
// то отправляем запрос на индикатив
// иначе на инструменты
const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;
if (isIndicativeInstrument) {
try {
const { data } = await indicativeQuotesController.getCandles({
count: 1,
key: currencyPair,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DDTHH:mm:ss'),
interval,
});
if (data.indicativeCandles.length === 0) {
return;
}
const sortedData = [...data.indicativeCandles].sort(
(a, b) => new Date(a.end).valueOf() - new Date(b.end).valueOf(),
);
const bar = candleToBar(sortedData[0]);
onRealtimeCallback?.(bar);
return bar;
} catch (e) {
console.error('error from requestRealTimeBars indicativeQuotesController: ', e);
}
} else {
try {
const { data } = await api.getBars({
currencyPair,
date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DD%20HH:mm:ss'),
interval,
count: 1,
ticker,
});
if (data.length === 0) {
return;
}
const sortedData = [...data].sort((a, b) => new Date(a.end).valueOf() - new Date(b.end).valueOf());
const bar = candleToBar(sortedData[0]);
onRealtimeCallback?.(bar);
return bar;
} catch (e) {
console.error('error from requestRealTimeBars: ', e);
}
}
};
import { SearchSymbolResultItem } from '@modules/charting_library/charting_library/datafeed-api';
import { Contract } from '@modules/contracts';
import { instrumentNames } from '@modules/quotes';
import { ContractsWithNewColumns } from '@widgets/Chart/datafeed';
const MAX_RESULTS = 500;
const matchesUserInput = (property: string | undefined | null, userInput: string) =>
property?.toLowerCase().includes(userInput);
export const filterUserInputWithNewColumns = (i: ContractsWithNewColumns, lowerCaseUserInput: string) =>
matchesUserInput(i.symbol, lowerCaseUserInput) ||
matchesUserInput(i.board, lowerCaseUserInput) ||
matchesUserInput(i.shortName, lowerCaseUserInput) ||
matchesUserInput(i.secName, lowerCaseUserInput) ||
matchesUserInput(i.latName, lowerCaseUserInput) ||
matchesUserInput(i.instrName, lowerCaseUserInput) ||
matchesUserInput(i.instrGroupType, lowerCaseUserInput) ||
matchesUserInput(i.boardName, lowerCaseUserInput) ||
matchesUserInput(i.ccy, lowerCaseUserInput) ||
matchesUserInput(i.instrFullName, lowerCaseUserInput) ||
matchesUserInput(i.instrSubtypeName, lowerCaseUserInput) ||
matchesUserInput(i.currencyType, lowerCaseUserInput) ||
matchesUserInput(i.symbolWithBoard, lowerCaseUserInput) ||
matchesUserInput(i.symbolWithBoardName, lowerCaseUserInput);
export const filterUserInput = (i: Contract, lowerCaseUserInput: string) =>
matchesUserInput(i.symbol, lowerCaseUserInput) ||
matchesUserInput(i.board, lowerCaseUserInput) ||
matchesUserInput(i.shortName, lowerCaseUserInput) ||
matchesUserInput(i.secName, lowerCaseUserInput) ||
matchesUserInput(i.latName, lowerCaseUserInput) ||
matchesUserInput(i.instrName, lowerCaseUserInput) ||
matchesUserInput(i.instrGroupType, lowerCaseUserInput) ||
matchesUserInput(i.boardName, lowerCaseUserInput) ||
matchesUserInput(i.ccy, lowerCaseUserInput) ||
matchesUserInput(i.instrFullName, lowerCaseUserInput) ||
matchesUserInput(i.instrSubtypeName, lowerCaseUserInput) ||
matchesUserInput(i.currencyType, lowerCaseUserInput);
export const mapToTVSearchObject = (instr: Contract | ContractsWithNewColumns) => ({
full_name: instr.displayName || '',
ticker: instr.issKey || '',
description: instr.boardName || '',
exchange: instrumentNames[instr.instrType || ''] || '',
type: '',
symbol: instr.displayName || '',
});
export const filterIsPrimary = (contracts: Contract[]) => contracts.filter(({ isPrimaryBoard }) => isPrimaryBoard);
export const searchSymbolFunction = (userInput: string, instruments: Contract[]): SearchSymbolResultItem[] => {
if (!instruments || instruments.length === 0) {
return [];
}
const contractsIsPrimary: Contract[] = filterIsPrimary(instruments);
const lowerCaseUserInput = userInput.toLowerCase();
const newResultsPrimary: SearchSymbolResultItem[] = contractsIsPrimary
.filter((i) => filterUserInput(i, lowerCaseUserInput))
.map(mapToTVSearchObject);
let result: SearchSymbolResultItem[] = [];
if (newResultsPrimary.length !== 0) {
result = newResultsPrimary;
} else {
// если не нашли среди отфильтрованных по isPrimaryBoard инструментов, то ищем среди всех
// и добавляем дополнительные поля для проверки пользовательского ввода в определенном формате
const contractsWithNewColumns: ContractsWithNewColumns[] = instruments.map((elem) => ({
...elem,
symbolWithBoard: `${elem.symbol} ${elem.board}`,
symbolWithBoardName: `${elem.symbol} ${elem.boardName}`,
}));
result = contractsWithNewColumns
.filter((i) => filterUserInputWithNewColumns(i, lowerCaseUserInput))
.map(mapToTVSearchObject);
}
return result.slice(0, MAX_RESULTS);
};