Загрузка данных
import {
BarData,
CustomData,
HistogramData,
HistogramSeries,
LineData,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesPartialOptionsMap,
Time,
} from 'lightweight-charts';
import { Ohlc } from '@core/Legend';
import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { t } from '@src/translations';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatCompactNumber, isHistogramData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export class HistogramSeriesStrategy extends BaseSeries<'Histogram'> implements ISeries<'Histogram'> {
constructor(params: BaseSeriesParams<'Histogram'>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
protected seriesDefinition(): SeriesDefinition<'Histogram'> {
return HistogramSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Histogram'] {
return {};
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
if (!Array.isArray(data)) {
return false;
}
return data.every(
(point) => typeof point.time === 'number' && typeof point.volume === 'number' && !Number.isNaN(point.volume),
);
}
public getTypeName(): string {
return 'Histogram';
}
protected dataSourceSubscription = (dataToSet: Candle[]): void => {
if (!this.validateData(dataToSet)) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
this.setData(this.formatData(dataToSet));
};
protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
if (!this.validateData([dataToSet])) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
const formattedData = this.formatData([dataToSet]);
this.update(formattedData[0]);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Histogram'][] {
return inputData.map((point) => ({
time: point.time as Time,
value: point.close,
customValues: point as unknown as Record<string, unknown>,
}));
}
protected formatLegendValues(
currentBar: null | BarData | LineData | HistogramData | CustomData,
prevBar: null | BarData | LineData | HistogramData | CustomData,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> {
if (!currentBar || !isHistogramData(currentBar)) {
return {};
}
const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;
return {
value: {
value: formatCompactNumber(currentBar.value) ?? '',
name: '',
color,
},
absoluteChange: {
value: formatCompactNumber(absoluteChange ?? 0),
name: t('Change'),
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatCompactNumber(percentageChange)}%` : '',
name: t('Change'),
color,
},
time: {
value: time,
name: t('Time'),
color,
},
};
}
}
import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';
import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';
import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';
export type SeriesStrategies =
| CandlestickSeriesStrategy
| LineSeriesStrategy
| HistogramSeriesStrategy
| BarSeriesStrategy
| ISeries<'Baseline'>
| ISeries<'Area'>
| ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
* Реализует паттерн Factory для создания нужной стратегии по типу графика
*/
export class SeriesFactory {
static create(
type: ChartSeriesType,
):
| ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
| ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
| ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
| ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
if (type === 'Candlestick') {
return ((params) => new CandlestickSeriesStrategy(params)) as (
params: BaseSeriesParams<'Candlestick'>,
) => CandlestickSeriesStrategy;
}
if (type === 'Histogram') {
return ((params) => new HistogramSeriesStrategy(params)) as (
params: BaseSeriesParams<'Histogram'>,
) => HistogramSeriesStrategy;
}
if (type === 'Line') {
return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
}
if (type === 'Bar') {
return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
}
throw new Error(`Unsupported chart type: ${type}`);
}
}
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 { INDICATOR_COLOR_PALETTE_MIDDLE_INDEX } from '@src/theme';
import { t } from '@src/translations';
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 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: {},
dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>),
},
],
settings: [
{ type: 'number', key: 'length', label: t('Length'), defaultValue: 10, min: 1, max: 500 },
{
type: 'select',
key: 'source',
label: t('Data'),
defaultValue: 'close',
options: [
{ label: t('Open price'), value: 'open' },
{ label: t('Max'), value: 'high' },
{ label: t('Min'), value: 'low' },
{ label: t('Close price'), value: 'close' },
],
},
{ type: 'number', key: 'offset', label: t('Offset'), defaultValue: 0, min: -100, max: 100 },
],
},
[IndicatorsIds.EMA]: {
paletteStartIndex: INDICATOR_COLOR_PALETTE_MIDDLE_INDEX,
series: [
{
name: 'Line', // todo: change with enum
id: 'ema',
seriesOptions: {},
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>);
},
},
],
settings: [
{ type: 'number', key: 'length', label: t('Length'), defaultValue: 10, min: 1, max: 500 },
{
type: 'select',
key: 'source',
label: t('Data'),
defaultValue: 'close',
options: [
{ label: t('Open price'), value: 'open' },
{ label: t('Max'), value: 'high' },
{ label: t('Min'), value: 'low' },
{ label: t('Close price'), value: 'close' },
],
},
{ type: 'number', key: 'offset', label: t('Offset'), 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,
},
},
{
name: 'Line', // todo: change with enum
id: 'oscillatorFastMa',
dataFormatter: (params) => macdOscillatorFastMa(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: 'macd_oscillator_ma',
visible: false,
lastValueVisible: false,
},
},
{
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,
},
},
{
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,
},
},
{
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: t('Data'),
defaultValue: 'close',
options: [
{ label: t('Open price'), value: 'open' },
{ label: t('Max'), value: 'high' },
{ label: t('Min'), value: 'low' },
{ label: t('Close price'), value: 'close' },
],
},
{ type: 'number', key: 'fastLength', label: t('Fast length'), defaultValue: 12, min: 1, max: 500 },
{ type: 'number', key: 'slowLength', label: t('Slow length'), defaultValue: 26, min: 1, max: 500 },
{ type: 'number', key: 'signalLength', label: t('Signal length'), defaultValue: 9, min: 1, max: 500 },
{
type: 'select',
key: 'oscillatorMaType',
label: t('Oscillator MA type'),
defaultValue: 'ema',
options: [
{ label: 'EMA', value: 'ema' },
{ label: 'SMA', value: 'sma' },
],
},
{
type: 'select',
key: 'signalMaType',
label: t('Signal MA type'),
defaultValue: 'ema',
options: [
{ label: 'EMA', value: 'ema' },
{ label: 'SMA', value: 'sma' },
],
},
],
},
});
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
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 { PaneManager } from '@core/PaneManager';
import { MAIN_PANE_INDEX } from '@src/constants';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { getPaletteColorFromIndex, normalizeColor, 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;
initialIndicators?: IndicatorSnapshot[];
}
const getDefaultCompareIndicatorConfig = (symbol: string, usedColors: string[]): IndicatorConfig => {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
newPane: true,
label: symbol,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
};
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,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
});
if (!initialIndicators) {
return;
}
this.setup(initialIndicators);
}
private async setup(initialIndicators: IndicatorSnapshot[]) {
for (const indicator of initialIndicators) {
if (indicator.config && indicator.config.label) {
// условие проверки compare ли это
const serie = indicator.config.series[0];
const compareMode =
serie.seriesOptions?.priceScaleId === Direction.Left
? CompareMode.NewScale
: indicator.config.newPane
? CompareMode.NewPane
: CompareMode.Percentage;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
}
}
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
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,
paneId?: number,
): 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 symbol$ = new BehaviorSubject(symbol);
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
);
const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ind.config?.series?.map((serie) => serie.seriesOptions?.color),
);
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
const config = getDefaultCompareIndicatorConfig(symbol, usedColors);
const associatedPane =
mode === CompareMode.NewPane
? paneId !== undefined
? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
: this.paneManager.addPane()
: this.paneManager.getMainPane();
return new Indicator({
id: key,
lwcChart: this.chart,
mainSymbol$: symbol$,
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
seriesOptions: {
...config.series[0]?.seriesOptions,
priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => this.removeByKey(key),
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), 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);
this.indicatorManager.removeEntity(entry.entity);
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,
});
}
}
}