Загрузка данных
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { IndicatorsIds } from '@src/core/Indicators';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { ChartSeriesType, ChartTypeOptions, OHLCConfig, TooltipConfig } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
// todo: forbid @lib in /src
export interface IMoexChart {
container: HTMLElement;
supportedTimeframes: Timeframes[];
initialTimeframe: Timeframes;
supportedChartSeriesTypes: ChartSeriesType[];
initialChartSeriesTypes: ChartSeriesType;
initialSymbol: string;
getDataSource: DataSourceParams['getData'];
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
initialIndicators?: IndicatorsIds[];
size?:
| {
width: number;
height: number;
}
| false;
mode?: ThemeMode; // 'light' | 'dark'
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
chartSettings?: ChartSettingsSource;
chartOptions?: ChartTypeOptions; // todo: разнести по разным полям в соответствии с тиами графика
tooltipConfig?: TooltipConfig;
openCompareModal?: () => void;
}
export class MoexChart {
private chart: Chart;
private resizeObserver?: ResizeObserver;
private eventManager: EventManager;
private rootContainer: HTMLElement;
private headerRenderer: UIRenderer;
private modalRenderer: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private timeScaleHoverController: TimeScaleHoverController;
private dataSource: DataSource;
private subscriptions = new Subscription();
private fullscreen: FullscreenController;
constructor(config: IMoexChart) {
setPricePrecision(config.ohlc.precision);
this.eventManager = new EventManager({
initialTimeframe: config.initialTimeframe,
initialSeries: config.initialChartSeriesTypes,
initialSymbol: config.initialSymbol,
initialChartOptions: config.chartOptions,
isMXT: config.theme === 'mxt',
});
if (config.initialIndicators) {
this.eventManager.setUserIndicators(config.initialIndicators);
}
if (config.chartSettings) {
this.setSettings(config.chartSettings);
}
this.dataSource = new DataSource({
getData: config.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config);
const {
chartAreaContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarRenderer
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.showMenuButton,
});
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
container: chartAreaContainer,
theme: store.theme,
mode: store.mode,
seriesTypes: config.supportedChartSeriesTypes,
dataSource: this.dataSource,
chartOptions: config.chartOptions, // todo: remove, use only model from eventManager
ohlcConfig: config.ohlc, // todo: omptimize
tooltipConfig: config.tooltipConfig ?? {},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.headerRenderer.renderComponent(
<Header
timeframes={config.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.showSettingsButton
? () =>
this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: 'Настройки' },
)
: undefined
}
toggleUserIndicator={this.eventManager.toggleUserIndicator}
activeIndicatorsObs={this.eventManager.getUserIndicatorsListArr()}
showMenuButton={config.showMenuButton}
showFullscreenButton={config.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.showCompareButton}
openCompareModal={config.openCompareModal ? config.openCompareModal : undefined}
isMXT={config.theme === 'mxt'}
/>,
);
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
if (config.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={(name) => {
// todo: deal with new panes logic
this.chart.getDrawingsManager().addDrawing(name);
}}
/>,
);
}
if (this.controlBarRenderer && config.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSymbol(symbol: string): void {
if (!symbol) return;
this.eventManager.setSymbol(symbol);
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.headerRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
if (this.eventManager) {
this.eventManager.destroy();
}
ContainerManager.clearContainers(this.rootContainer);
}
}
import dayjs from 'dayjs';
import {
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import {
Candle,
ChartOptionsModel,
ChartSeriesType,
ChartTypeOptions,
Direction,
OHLCConfig,
TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ensureDefined } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 50;
interface ChartParams extends ChartConfig {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart {
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbols: string[] = [];
private historyBatchRunning = false;
constructor({ eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, ...lwcConfig }: ChartParams) {
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcConfig.container;
this.chartConfig = lwcConfig;
this.lwcChart = createChart(this.container, getOptions(lwcConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
const configToApply = { ...lwcConfig, dateFormat, timeFormat, showTime };
this.lwcChart.applyOptions({
...getOptions(configToApply),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({ modalRenderer });
this.paneManager = new PaneManager({
eventManager: this.eventManager,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
});
this.indicatorManager = new IndicatorManager({
eventManager,
DOM: this.DOM,
mainSerie$: this.mainSeries,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
});
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsManager = (): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({ history: false });
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({ history: false });
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
}
public destroy(): void {
this.mouseEvents.destroy();
this.compareManager.clear();
this.subscriptions.unsubscribe();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({ history: false });
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({ history: false });
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({ history: false });
this.lwcChart.timeScale().resetTimeScale();
this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbols,
update: (symbol: string, candle: Candle) => {
this.dataSource.updateRealtime(symbol, candle);
},
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from as number;
};
const warmupSymbols = (symbols: string[]) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const prevSymbols = this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const prevSet = new Set(prevSymbols);
const added: string[] = [];
for (let i = 0; i < symbols.length; i += 1) {
const s = symbols[i];
if (!s) continue;
if (prevSet.has(s)) continue;
added.push(s);
}
if (added.length) {
warmupSymbols(added);
}
}),
);
this.subscriptions.add(
combineLatest([
this.eventManager.symbol(),
this.compareManager.itemsObs(),
this.mainSeries.asObservable(),
]).subscribe(([main, items, serie]) => {
if (!serie) return;
const title = items.length ? main : '';
serie.getLwcSeries().applyOptions({ title });
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
if (!logicalRange) return;
if (this.currentInterval === Intervals.All) return;
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function getIntervalRange(interval: Intervals): { from: number; to: number } {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
return { from, to };
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
const timeFormat = config.timeFormat ?? Defaults.timeFormat;
const showTime = config.showTime ?? Defaults.showTime;
const use12HourFormat = timeFormat === '12h';
const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';
const { colors } = getThemeStore();
return {
width: config.container.clientWidth,
height: config.container.clientHeight,
autoSize: true,
layout: {
background: { color: colors.chartBackground },
textColor: colors.chartTextPrimary,
},
grid: {
vertLines: { color: colors.chartGridLine },
horzLines: { color: colors.chartGridLine },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
},
timeScale: {
timeVisible: showTime,
secondsVisible: false,
tickMarkFormatter: createTickMarkFormatter(timeFormatString),
borderVisible: false,
allowBoldLabels: false,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
};
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable, throttleTime } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@core/Indicators';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';
interface SeriesParams {
eventManager: EventManager;
dataSource: DataSource;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
chartOptions?: ChartTypeOptions;
}
export class IndicatorManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private chartOptions?: ChartTypeOptions;
private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
private indicatorsMap$: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject(new Map());
private DOM: DOMModel;
constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.eventManager.getUserIndicatorsList().subscribe((ids: Set<IndicatorsIds>) => {
const indicatorsMap = new Map(this.indicatorsMap$.value);
ids.forEach((id: IndicatorsIds) => {
if (!indicatorsMap.has(id)) {
const ind = this.addEntity<Indicator>(
(zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Indicator({
id,
lwcChart,
mainSymbol$: this.eventManager.getSymbol(),
dataSource,
chartOptions,
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
paneManager,
}),
);
indicatorsMap.set(id, ind);
}
});
// todo: move to deleteSeries
const indicatorsIdsToDelete = Array.from(indicatorsMap.keys()).filter((existingKey) => {
return !ids.has(existingKey);
});
indicatorsIdsToDelete.forEach((s: IndicatorsIds) => {
// todo: move to deleteSeries
const indicatorToDestroy = indicatorsMap.get(s);
if (!indicatorToDestroy) return;
this.removeEntity(indicatorToDestroy);
indicatorToDestroy.destroy();
indicatorsMap.delete(s);
});
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
});
}
public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string) => {
this.eventManager.toggleUserIndicator(id as IndicatorsIds);
};
public entities(): Observable<Indicator[]> {
return this.entities$.asObservable();
}
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription, throttleTime } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { IndicatorConfig, IndicatorsIds, indicatorsMap } from '@core/Indicators';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';
import { ensureDefined } from '@src/utils';
type IIndicator = DOMObject;
export interface IndicatorParams extends DOMObjectParams {
mainSymbol$: Observable<string>;
lwcChart: IChartApi;
dataSource: DataSource;
paneManager: PaneManager;
chartOptions?: ChartTypeOptions;
config?: IndicatorConfig;
}
export class Indicator extends DOMObject implements IIndicator {
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
private lwcChart: IChartApi;
private associatedPane: Pane;
private subscriptions = new Subscription();
constructor({
id,
lwcChart,
dataSource,
zIndex,
onDelete,
moveUp,
moveDown,
mainSymbol$,
paneManager,
config,
}: IndicatorParams) {
super({ id: id as string, zIndex, onDelete, moveUp, moveDown });
this.lwcChart = lwcChart;
const indicatorConfig = indicatorsMap[id as IndicatorsIds] ?? config;
const { series, newPane } = ensureDefined(indicatorConfig);
this.associatedPane = newPane ? paneManager.addPane() : paneManager.getMainPane();
series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
const serie = SeriesFactory.create(name!)({
lwcChart,
dataSource,
customFormatter: dataFormatter,
seriesOptions,
priceScaleOptions,
mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
paneIndex: this.associatedPane.getId(),
indicatorReference: this,
});
this.seriesMap.set(serieId, serie);
this.series.push(serie);
});
this.associatedPane.setIndicator(id as IndicatorsIds, this);
}
public subscribeDataChange(handler: () => void): Subscription {
// todo: возможно нужно переписать завязавшись гна this.associatedPane.getMainSerie()
const firer = new BehaviorSubject<number>(0);
this.series.forEach((serie) => {
const handle = () => {
firer.next(firer.value + 1);
};
serie.subscribeDataChanged(handle);
this.subscriptions.add(() => serie.unsubscribeDataChanged(handle));
});
return firer.pipe(throttleTime(500)).subscribe(() => {
handler();
});
}
public getPane(): Pane {
return this.associatedPane;
}
public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public show() {
this.series.forEach((s) => {
s.show();
});
super.show();
}
public hide() {
this.series.forEach((s) => {
s.hide();
});
super.hide();
}
// destroy и delete принципиально отличаются!
// delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
public delete() {
super.delete();
}
// destroy и delete принципиально отличаются!
public destroy() {
this.subscriptions.unsubscribe();
this.series.forEach((s) => {
s.destroy();
});
this.associatedPane.removeIndicator(this.id as IndicatorsIds);
}
}
import {
DeepPartial,
PriceScaleMode,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdSignal } from '@core/Indicators/macd';
import { smaIndicator } from '@core/Indicators/sma';
import { volume } from '@core/Indicators/volume';
import { SerieData } from '@core/Series/BaseSeries';
import { Candle, ChartSeriesType } from '@lib';
import { getThemeStore } from '@src/theme';
import { Direction, LineCandle } from '@src/types';
export interface IndicatorConfig {
series: {
id: string;
name: ChartSeriesType;
seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
priceScaleId?: string;
dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
}[];
newPane?: boolean;
}
export enum IndicatorsIds {
'vol' = 'vol',
'sma' = 'sma',
'ema' = 'ema',
'macd' = 'macd',
}
export type ChartTypeToCandleData = {
['Bar']: Candle;
['Candlestick']: Candle;
['Area']: LineCandle;
['Baseline']: LineCandle;
['Line']: LineCandle;
['Histogram']: LineCandle;
['Custom']: Candle;
};
export interface IndicatorDataFormatter<T extends SeriesType> {
mainSeriesData: SerieData[];
selfData: ChartTypeToCandleData[T][];
candle?: SerieData;
indicatorReference?: Indicator;
}
export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
[IndicatorsIds.vol]: {
series: [
{
name: 'Histogram',
id: 'volume',
priceScaleOptions: {
scaleMargins: { top: 0.7, bottom: 0 },
},
seriesOptions: {
priceScaleId: 'vol',
priceFormat: {
type: 'volume',
},
},
dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
},
],
},
[IndicatorsIds.sma]: {
series: [
{
name: 'Line', // todo: change with enum
id: 'sma',
seriesOptions: {
color: getThemeStore().colors.indicatorLineSma,
},
dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>, 10),
},
],
},
[IndicatorsIds.ema]: {
series: [
{
name: 'Line', // todo: change with enum
id: 'ema',
seriesOptions: {
color: getThemeStore().colors.indicatorLineEma,
},
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 25);
},
},
],
},
[IndicatorsIds.macd]: {
newPane: true,
series: [
{
name: 'Line', // todo: change with enum
id: 'longEma',
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 26);
},
seriesOptions: {
priceScaleId: 'macd_emas',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'shortEma',
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 12);
},
seriesOptions: {
priceScaleId: 'macd_emas',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'macd',
priceScaleOptions: {
mode: PriceScaleMode.Normal,
},
dataFormatter: (params) => macdLine(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
color: getThemeStore().colors.indicatorLineSma,
},
},
{
name: 'Line', // todo: change with enum
id: 'macd_signal',
priceScaleOptions: {
mode: PriceScaleMode.Normal,
},
dataFormatter: (params) => macdSignal(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
color: getThemeStore().colors.chartCandleWickUp,
},
},
{
name: 'Histogram', // todo: change with enum
id: 'hist',
priceScaleOptions: {
autoScale: true,
},
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
},
dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
},
],
},
};