Загрузка данных
import { IChartApi } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';
import { indicatorLabelById, indicatorSeriesLabelById, IndicatorsIds } from '@src/constants';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, SettingsValues } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';
type IIndicator = DOMObject;
export interface IndicatorParams extends DOMObjectParams {
mainSymbol$: Observable<string>;
lwcChart: IChartApi;
dataSource: DataSource;
associatedPane: Pane;
config: IndicatorConfig;
type?: IndicatorsIds;
chartOptions?: ChartTypeOptions;
}
export class Indicator extends DOMObject implements ISerializable<IndicatorSnapshot> {
private indicatorType?: IndicatorsIds;
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
private lwcChart: IChartApi;
private dataSource: DataSource;
private mainSymbol$: Observable<string>;
private associatedPane: Pane;
private config: IndicatorConfig;
private settings: SettingsValues = {};
private dataChangeHandlers = new Set<() => void>();
private seriesSubscriptions = new Subscription();
constructor({
id,
type,
lwcChart,
dataSource,
zIndex,
onDelete,
moveUp,
moveDown,
mainSymbol$,
associatedPane,
paneId,
config,
}: IndicatorParams) {
super({ id, name: config?.label ?? id, zIndex, onDelete, moveUp, moveDown, paneId });
this.lwcChart = lwcChart;
this.dataSource = dataSource;
this.mainSymbol$ = mainSymbol$;
this.indicatorType = type;
this.config = config;
this.name = this.getLabel();
this.settings = this.getDefaultSettings();
this.associatedPane = associatedPane;
this.createSeries();
this.associatedPane.setIndicator(this.id, this);
}
public subscribeDataChange(handler: () => void): Subscription {
this.dataChangeHandlers.add(handler);
return new Subscription(() => {
this.dataChangeHandlers.delete(handler);
});
}
public deletable = (): boolean => {
return this.associatedPane.isMainPane() || this.associatedPane.isLast();
};
public getLabel = () => {
if (this.config.label) {
return this.config.label;
}
if (this.indicatorType) {
return indicatorLabelById[this.indicatorType];
}
return this.id;
};
public getSeriesLabel(serieName: string): string | undefined {
if (this.config.seriesLabels?.[serieName]) {
return this.config.seriesLabels[serieName];
}
if (this.indicatorType) {
return indicatorSeriesLabelById[this.indicatorType]?.[serieName];
}
return undefined;
}
public getId(): string {
return this.id;
}
public getType(): IndicatorsIds | undefined {
return this.indicatorType;
}
public getPane(): Pane {
return this.associatedPane;
}
public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public getSettingsConfig() {
return this.config.settings ?? [];
}
public hasSettings(): boolean {
return Boolean(this.config.settings?.length);
}
public updateSettings(settings: SettingsValues): void {
this.settings = settings;
// todo: обновлять данные серий без удаления и повторного создания
this.destroySeries();
this.createSeries();
this.notifyDataChanged();
}
public show() {
this.series.forEach((s) => {
s.show();
});
super.show();
}
public hide() {
this.series.forEach((s) => {
s.hide();
});
super.hide();
}
public override getSnapshot(): DOMObjectSnapshot & IndicatorSnapshot {
const domSnap = super.getSnapshot();
return {
...domSnap,
dataSource: this.dataSource,
indicatorType: this.indicatorType,
config: this.config,
};
}
public setSnapshot(snap: IndicatorSnapshot): void {}
// destroy и delete принципиально отличаются!
// delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
public delete() {
super.delete();
}
// destroy и delete принципиально отличаются!
public destroy() {
this.destroySeries();
this.dataChangeHandlers.clear();
this.associatedPane.removeIndicator(this.id);
}
private createSeries(): void {
this.config.series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
const serie = SeriesFactory.create(name!)({
lwcChart: this.lwcChart,
dataSource: this.dataSource,
customFormatter: dataFormatter
? (params) =>
dataFormatter({
...params,
settings: this.settings,
indicatorReference: this,
})
: undefined,
seriesOptions,
priceScaleOptions,
mainSymbol$: this.mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
paneIndex: this.associatedPane.getId(),
indicatorReference: this,
});
const handleDataChanged = () => {
this.notifyDataChanged();
};
serie.subscribeDataChanged(handleDataChanged);
this.seriesSubscriptions.add(() => {
serie.unsubscribeDataChanged(handleDataChanged);
});
this.seriesMap.set(serieId, serie);
this.series.push(serie);
});
}
private destroySeries(): void {
this.seriesSubscriptions.unsubscribe();
this.seriesSubscriptions = new Subscription();
this.series.forEach((s) => {
s.destroy();
});
this.series = [];
this.seriesMap.clear();
}
private notifyDataChanged(): void {
this.dataChangeHandlers.forEach((handler) => {
handler();
});
}
private getDefaultSettings(): SettingsValues {
const settings: SettingsValues = {};
this.config.settings?.forEach((field) => {
settings[field.key] = field.defaultValue;
});
return settings;
}
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
interface SeriesParams {
eventManager: EventManager;
dataSource: DataSource;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
initialIndicators?: IndicatorSnapshot[];
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<string, Indicator>> = new BehaviorSubject(new Map()); // todo: заменить IndicatorsIds ключ на уникальный id индикатора
private DOM: DOMModel;
private dataSource: DataSource;
private paneManager: PaneManager;
constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, initialIndicators, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.dataSource = dataSource;
this.paneManager = paneManager;
this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
initialIndicators?.forEach((ind) => {
this.addIndicator(ind);
});
}
public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
}
public addIndicator(snap: Partial<IndicatorSnapshot>): void {
if (!snap.indicatorType) {
console.error('[IndicatorManager] Не был получен тип индиктора');
return;
}
const indicatorsMap = new Map(this.indicatorsMap$.value);
const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;
const config = snap.config ?? (indicatorsConfigMap[snap.indicatorType] as IndicatorConfig);
const associatedPane =
snap.paneId !== undefined
? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
: config?.newPane
? this.paneManager.addPane()
: this.paneManager.getMainPane();
const indicatorToSet = this.addEntity<Indicator>(
(zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Indicator({
id,
paneId: associatedPane.getId(),
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
mainSymbol$: this.eventManager.getSymbol(),
lwcChart: this.lwcChart,
dataSource: this.dataSource,
associatedPane,
config,
type: snap.indicatorType,
chartOptions: this.chartOptions,
}),
);
indicatorsMap.set(id, indicatorToSet);
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
}
public getIndicators() {
return this.indicatorsMap$.pipe(map((indicators) => Array.from(indicators.keys())));
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string) => {
const indicatorsMap = new Map(this.indicatorsMap$.value);
const entity = indicatorsMap.get(id);
if (!entity) {
return;
}
this.removeEntity(entity);
entity.destroy();
indicatorsMap.delete(id);
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
};
public entities(): Observable<Indicator[]> {
return this.entities$.asObservable();
}
}
import { PriceScaleMode, SeriesType } from 'lightweight-charts';
import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdOscillatorFastMa, macdOscillatorSlowMa, 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 } from '@lib';
import { IndicatorsIds } from '@src/constants';
import { getThemeStore } from '@src/theme';
import { Direction, IndicatorConfig, LineCandle, SettingsValues } from '@src/types';
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;
settings?: SettingsValues;
}
export function applyIndicatorColor(config: IndicatorConfig, color: string): IndicatorConfig {
return {
...config,
series: config.series.map((series) => {
const isVisibleLine = series.name === 'Line' && series.seriesOptions?.visible !== false;
if (!isVisibleLine) {
return series;
}
return {
...series,
seriesOptions: {
...series.seriesOptions,
color,
},
};
}),
};
}
export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
[IndicatorsIds.Volume]: {
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'>),
},
],
settings: [
{ type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1, max: 500 },
{
type: 'select',
key: 'source',
label: 'Данные',
defaultValue: 'close',
options: [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
],
},
{ type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
],
},
[IndicatorsIds.EMA]: {
series: [
{
name: 'Line', // todo: change with enum
id: 'ema',
seriesOptions: {
color: getThemeStore().colors.indicatorLineEma,
},
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>);
},
},
],
settings: [
{ type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1, max: 500 },
{
type: 'select',
key: 'source',
label: 'Данные',
defaultValue: 'close',
options: [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
],
},
{ type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
],
},
[IndicatorsIds.MACD]: {
newPane: true,
series: [
{
name: 'Line', // todo: change with enum
id: 'oscillatorSlowMa',
dataFormatter: (params) => macdOscillatorSlowMa(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: 'macd_oscillator_ma',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'oscillatorFastMa',
dataFormatter: (params) => macdOscillatorFastMa(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: 'macd_oscillator_ma',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'macdLine',
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: 'signalLine',
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: 'histogram',
priceScaleOptions: {
autoScale: true,
},
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
},
dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
},
],
settings: [
{
type: 'select',
key: 'source',
label: 'Данные',
defaultValue: 'close',
options: [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
],
},
{ type: 'number', key: 'fastLength', label: 'Длина Fast', defaultValue: 12, min: 1, max: 500 },
{ type: 'number', key: 'slowLength', label: 'Длина Slow', defaultValue: 26, min: 1, max: 500 },
{ type: 'number', key: 'signalLength', label: 'Signal length', defaultValue: 9, min: 1, max: 500 },
{
type: 'select',
key: 'oscillatorMaType',
label: 'Oscillator MA type',
defaultValue: 'ema',
options: [
{ label: 'EMA', value: 'ema' },
{ label: 'SMA', value: 'sma' },
],
},
{
type: 'select',
key: 'signalMaType',
label: 'Signal MA type',
defaultValue: 'ema',
options: [
{ label: 'EMA', value: 'ema' },
{ label: 'SMA', value: 'sma' },
],
},
],
},
};
import {
DeepPartial,
HistogramData,
LineData,
LineWidth,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
WhitespaceData,
} from 'lightweight-charts';
import { indicatorLabelById, IndicatorsIds } from '@src/constants';
import { IndicatorDataFormatter } from '@src/core/Indicators';
import { ChartSeriesType } from '@src/types/chart';
import { SettingField } from '@src/types/settings';
export type IndicatorData = HistogramData<Time> | LineData<Time> | WhitespaceData<Time>;
export type IndicatorType = 'SMA' | 'EMA' | 'RSI' | 'OHLC' | 'VOL';
export type MASource = 'open' | 'high' | 'low' | 'close';
export type IndicatorLabel = (typeof indicatorLabelById)[IndicatorsIds];
export interface IndicatorStateConfig {
type: IndicatorType;
id?: IndicatorsIds;
period?: number;
color?: string;
lineWidth?: LineWidth;
visible?: boolean;
params?: Record<string, any>;
pane?: string;
}
export interface IndicatorSerie {
id: string;
name: ChartSeriesType;
seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
priceScaleId?: string;
dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
}
export interface IndicatorConfig {
series: IndicatorSerie[];
settings?: SettingField[];
newPane?: boolean;
label?: string;
seriesLabels?: Record<string, string>;
}