Загрузка данных
import { MouseEventParams, Point, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@core/Indicators';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, OHLCConfig } from '@src/types';
import { ensureDefined } from '@src/utils';
export interface LegendParams {
config: OHLCConfig;
eventManager: EventManager;
indicators: BehaviorSubject<Map<IndicatorsIds, Indicator>>;
subscribeChartEvent: ChartMouseEvents['subscribe'];
mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
paneId: number;
}
export interface Ohlc {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
absoluteChange?: number;
percentageChange?: number;
}
export interface CompareLegendItem {
symbol: string;
mode: CompareMode;
value: string;
color: string;
}
export interface LegendVM {
vm: LegendModel;
}
export type LegendModel = {
name: IndicatorsIds | string;
isIndicator: boolean;
values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
remove: () => void;
}[];
export const ohlcValuesToShowForMainSerie = [
// 'time',
'open',
'high',
'low',
'close',
'value',
// 'absoluteChange',
'percentageChange',
];
export const ohlcValuesToShowForIndicators = [
// 'time',
'open',
// 'high',
// 'low',
// 'close',
'value',
// 'absoluteChange',
// 'percentageChange'
];
export class Legend {
private eventManager: EventManager;
private indicators: Map<IndicatorsIds, Indicator> = new Map();
private config: OHLCConfig;
private mainSeries!: SeriesStrategies;
private isChartHovered = false;
private model$ = new BehaviorSubject<LegendModel>([]);
private tooltipVisability = new BehaviorSubject<boolean>(false);
private tooltipPos = new BehaviorSubject<null | Point>(null);
private paneId: number;
constructor({ config, eventManager, indicators, subscribeChartEvent, mainSeries, paneId }: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
if (!mainSeries) {
indicators.subscribe((value: Map<IndicatorsIds, Indicator>) => {
this.indicators = value;
this.handleIndicatorSeriesDataChange();
});
} else {
combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
if (!mainSerie) {
return;
}
this.mainSeries = mainSerie;
this.indicators = inds;
this.handleMainSeriesDataChange();
});
}
subscribeChartEvent('crosshairMove', this.handleCrosshairMove);
}
public subscribeCursorPosition(cb: (point: Point | null) => void) {
this.tooltipPos.subscribe(cb);
}
public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
this.tooltipVisability.subscribe(cb);
}
private handleIndicatorSeriesDataChange = () => {
for (const [_, indicator] of this.indicators) {
indicator?.subscribeDataChange(this.updateWithLastCandle);
}
};
private handleMainSeriesDataChange = () => {
this.mainSeries?.subscribeDataChanged(() => {
this.updateWithLastCandle();
});
};
private updateWithLastCandle = () => {
if (this.isChartHovered) return;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData();
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
name: this.mainSeries.options().title,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
remove: () => {
// serieData.remove() // todo: запретить удалять главную серию на главном пейне
},
});
}
if (!this.indicators) {
return;
}
for (const [indicatorName, indicator] of this.indicators) {
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData();
const value = serieData.value ?? serieData.close;
indicatorSeries.set(serieName, { ...value });
}
model.push({
name: indicatorName,
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
});
}
this.model$.next(model);
};
private handleCrosshairMove = (param: MouseEventParams) => {
// todo: есть одинаковый код с updateWithLastCandle
if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
this.tooltipVisability.next(false);
this.isChartHovered = false;
this.updateWithLastCandle();
return;
}
if (this.paneId === param.paneIndex) {
this.tooltipVisability.next(true);
} else {
this.tooltipVisability.next(false);
}
this.isChartHovered = true;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData(param);
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
name: this.mainSeries.options().title,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
remove: () => {
// serieData.remove() // todo: запретить удалять главную серию на главном пейне
},
});
}
if (!this.indicators) {
return;
}
for (const [indicatorName, indicator] of this.indicators) {
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData(param);
const value = ensureDefined(serieData.value ?? serieData.close);
indicatorSeries.set(serieName, value);
}
model.push({
name: indicatorName,
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
});
}
this.model$.next(model);
this.tooltipPos.next(param.point);
};
public getConfig(): OHLCConfig {
return this.config;
}
public getLegendViewModel(): Observable<LegendModel> {
return this.model$;
}
public destroy = () => {
this.model$.complete();
this.tooltipVisability.complete();
this.tooltipPos.complete();
};
}
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { IndicatorConfig, IndicatorDataFormatter } from '@core/Indicators';
import { emaIndicator } from '@core/Indicators/ema';
import { PaneManager } from '@core/PaneManager';
import { MAIN_PANE_INDEX } from '@src/constants';
// import { CompareIndicator } from '@src/core/CompareIndicator';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { CompareItem, CompareMode, Direction } from '@src/types';
import { normalizeSymbol } from '@src/utils';
interface CompareEntry {
key: string;
symbol: string;
mode: CompareMode;
paneIndex: number;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
function makeKey(symbol: string, mode: CompareMode): string {
return `${symbol}|${mode}`;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
}
const defaultCompareIndicator: IndicatorConfig = {
newPane: true,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${Math.random()}`,
seriesOptions: {
visible: true,
color: getThemeStore().colors.chartPriceLineText,
},
},
],
};
export class CompareManager {
private readonly chart: IChartApi;
private readonly eventManager: EventManager;
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
constructor({ chart, eventManager, dataSource, indicatorManager, paneManager }: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
});
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
public entitiesObs(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
public snapshot(): CompareItem[] {
return this.itemsSubject.value;
}
public clear(): void {
const keys = Array.from(this.entries.keys());
for (let i = 0; i < keys.length; i += 1) {
this.removeByKey(keys[i]);
}
this.applyPolicy();
this.publish();
}
public async setSymbolMode(seriesType: SeriesType, symbolRaw: string, mode: CompareMode): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled()) {
return;
}
const key = makeKey(symbol, mode);
if (this.entries.has(key)) return;
const { paneIndex, priceScaleId } = this.getPlacement(mode);
const symbol$ = new BehaviorSubject(symbol);
const entity = this.indicatorManager.addEntity<Indicator>(
(zIndex, moveUp, moveDown) =>
new Indicator({
id: key,
lwcChart: this.chart,
mainSymbol$: new BehaviorSubject<string>(symbol),
dataSource: this.dataSource,
zIndex,
onDelete: () => this.removeByKey(key),
moveUp,
moveDown,
paneManager: this.paneManager,
config: {
...defaultCompareIndicator,
series: [
{
...defaultCompareIndicator.series[0],
seriesOptions: {
...defaultCompareIndicator.series[0]?.seriesOptions,
priceScaleId,
},
},
],
newPane: mode === CompareMode.NewPane,
},
}),
);
this.entries.set(key, { key, symbol, mode, paneIndex, symbol$, entity });
this.applyPolicy();
this.publish();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
this.removeByKey(makeKey(symbol, mode));
this.applyPolicy();
this.publish();
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
const all = Array.from(this.entries.entries());
for (let i = 0; i < all.length; i += 1) {
const [key, entry] = all[i];
if (entry.symbol === symbol) this.removeByKey(key);
}
this.applyPolicy();
this.publish();
}
public isNewScaleDisabled(): boolean {
return this.itemsSubject.value.length > 0;
}
public isNewScaleDisabledObservable(): Observable<boolean> {
return this.itemsSubject.pipe(
map((items) => items.length > 0),
distinctUntilChanged(),
);
}
public getAllEntities() {
return Array.from(this.entries.values()).map(({ symbol, entity, mode }) => ({
symbol,
entity,
mode,
}));
}
public destroy(): void {
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private removeByKey(key: string): void {
const entry = this.entries.get(key);
if (!entry) return;
this.entries.delete(key);
entry.entity.destroy();
entry.symbol$.complete();
this.applyPolicy();
this.publish();
}
private publish(): void {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
const entities: Indicator[] = [];
for (let i = 0; i < values.length; i += 1) {
items.push({ symbol: values[i].symbol, mode: values[i].mode });
entities.push(values[i].entity);
}
this.itemsSubject.next(items);
this.entitiesSubject.next(entities);
}
private applyPolicy(): void {
const list = Array.from(this.entries.values());
let percentEnabled = false;
for (let i = 0; i < list.length; i += 1) {
if (list[i].mode === CompareMode.Percentage) {
percentEnabled = true;
break;
}
}
let hasMainNewScale = false;
for (let i = 0; i < list.length; i += 1) {
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
hasMainNewScale = true;
break;
}
}
const paneSet = new Set<number>();
for (let i = 0; i < list.length; i += 1) {
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
}
this.chart.applyOptions({
leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
});
this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
visible: true,
mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
});
this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
visible: hasMainNewScale,
mode: PriceScaleMode.Normal,
borderVisible: false,
});
const panes = Array.from(paneSet.values());
for (let i = 0; i < panes.length; i += 1) {
const paneIndex = panes[i];
this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
visible: true,
mode: PriceScaleMode.Normal,
});
if (hasMainNewScale) {
this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
visible: true,
mode: PriceScaleMode.Normal,
borderVisible: false,
});
}
}
for (let i = 0; i < list.length; i += 1) {
const entry = list[i];
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const serie = Array.from(entry.entity.getSeriesMap())[0][1];
const paneIndex = serie.getPane().paneIndex();
if (paneIndex !== MAIN_PANE_INDEX) {
serie.applyOptions({ priceScaleId: Direction.Right });
continue;
}
serie.applyOptions({
priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
}
private getPlacement(mode: CompareMode): { paneIndex: number; priceScaleId: Direction } {
const priceScaleId = mode === CompareMode.NewScale ? Direction.Left : Direction.Right;
const paneIndex = mode === CompareMode.NewPane ? this.paneManager.getPanes().size - 1 : MAIN_PANE_INDEX;
return { paneIndex, priceScaleId };
}
}
import { DataSource } from '@core/DataSource';
import { DrawingsManager } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
type PaneManagerParams = Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager {
private mainPane: Pane;
private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
private panesMap: Map<number, Pane> = new Map<number, Pane>();
constructor(params: PaneManagerParams) {
this.paneChartInheritedParams = { ...params, isMainPane: false };
this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(0, this.mainPane);
}
public getPanes() {
return this.panesMap;
}
public getMainPane: () => Pane = () => {
return this.mainPane;
};
public addPane(dataSource?: DataSource): Pane {
const panesOverallCount = this.panesMap.size;
const pane = new Pane({
...this.paneChartInheritedParams,
id: panesOverallCount,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => {
this.panesMap.delete(panesOverallCount);
},
});
this.panesMap.set(panesOverallCount, pane);
return pane;
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
}