Загрузка данных
import {
BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
CustomData,
DataChangedHandler,
DeepPartial,
HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
IPriceLine,
IPriceScaleApi,
IRange,
ISeriesApi,
ISeriesPrimitive,
LineData,
MismatchDirection,
MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesOptionsMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import {
BehaviorSubject,
distinctUntilChanged,
Observable,
Subscription,
} from 'rxjs';
import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import {
ChartTypeToCandleData,
IndicatorDataFormatter,
} from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import {
Candle,
Direction,
} from '@src/types';
import {
formatPrice,
getPricePrecisionStep,
isBarData,
isLineData,
normalizeSeriesData,
} from '@src/utils';
export interface SerieData {
time: Time;
customValues: Candle;
}
export interface CreateSeriesParams<
TSeries extends SeriesType,
> {
chart: IChartApi;
seriesOptions?:
SeriesPartialOptionsMap[TSeries];
paneIndex?: number;
priceScaleOptions?:
DeepPartial<PriceScaleOptions>;
}
export interface IBaseSeries<
TSeries extends SeriesType,
> extends ISeriesApi<TSeries> {
getLwcSeries: () => ISeriesApi<TSeries>;
getLegendData: (
param?: MouseEventParams,
) => Partial<
Record<
keyof Ohlc,
{
value:
| number
| string
| Time;
color: string;
name: string;
}
>
>;
}
export interface BaseSeriesParams<
TSeries extends SeriesType = SeriesType,
> {
lwcChart: IChartApi;
dataSource: DataSource;
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<
SeriesStrategies | null
>;
customFormatter?: (
params:
IndicatorDataFormatter<TSeries>,
) => SeriesDataItemTypeMap<
Time
>[TSeries][];
seriesOptions?:
SeriesPartialOptionsMap[TSeries];
priceScaleOptions?:
DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
paneIndex?: number;
indicatorReference?: Indicator;
}
function applyMoscowTimezone(
candles: Candle[],
): ChartTypeToCandleData['Candlestick'][] {
// todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
const offsetMinutes = -180; // utc.time - moscow.time in minutes
const secondsInMinute = 60;
return candles
.filter(
(candle) =>
typeof candle.time === 'number' &&
Number.isFinite(candle.time),
)
.map((candle) => ({
...candle,
time:
(candle.time as number) -
offsetMinutes *
secondsInMinute,
}));
}
export abstract class BaseSeries<
TSeries extends SeriesType,
> implements IBaseSeries<TSeries> {
protected lwcSeries:
ISeriesApi<TSeries>;
protected customFormatter:
| undefined
| ((
params:
IndicatorDataFormatter<TSeries>,
) => SeriesDataItemTypeMap<
Time
>[TSeries][]);
protected lwcChart: IChartApi;
protected mainSymbol$:
Observable<string>;
protected mainSerie$:
BehaviorSubject<
SeriesStrategies | null
>;
protected paneIndex:
| number
| null = null;
protected indicatorReference:
| Indicator
| null = null;
protected showSymbolLabel: boolean;
private subscriptions =
new Subscription();
private dataSub:
| Subscription
| null = null;
private realtimeSub:
| Subscription
| null = null;
constructor({
lwcChart,
mainSymbol$,
mainSerie$,
customFormatter,
seriesOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
indicatorReference,
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
seriesOptions,
paneIndex,
priceScaleOptions,
});
this.lwcChart = lwcChart;
this.customFormatter =
customFormatter;
this.mainSymbol$ = mainSymbol$;
this.mainSerie$ = mainSerie$;
this.showSymbolLabel =
showSymbolLabel;
this.indicatorReference =
indicatorReference ?? null;
}
public getLegendData = (
param?: MouseEventParams,
): Partial<
Record<
keyof Ohlc,
{
value:
| number
| string
| Time;
color: string;
name: string;
}
>
> => {
if (!param) {
const seriesData = this.data();
if (seriesData.length < 1) {
return {};
}
const dataToFormat =
seriesData[
seriesData.length - 1
];
const prevBarData =
seriesData.length > 1
? seriesData[
seriesData.length - 2
]
: null;
return this.formatLegendValues(
dataToFormat,
prevBarData,
);
}
const dataToFormat =
param.seriesData.get(
this.getLwcSeries(),
) ?? null;
const prevBarData =
param.logical === null
? null
: this.dataByIndex(
param.logical - 1,
);
return this.formatLegendValues(
dataToFormat,
prevBarData,
);
};
public show(): void {
this.lwcSeries.applyOptions({
...this.lwcSeries.options(),
visible: true,
});
}
public hide(): void {
this.lwcSeries.applyOptions({
...this.lwcSeries.options(),
visible: false,
});
}
public isVisible(): boolean {
return this.lwcSeries.options()
.visible;
}
public destroy(): void {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.subscriptions.unsubscribe();
this.lwcChart.removeSeries(
this.lwcSeries,
);
}
public getLwcSeries():
ISeriesApi<TSeries> {
return this.lwcSeries;
}
public applyOptions(
options:
SeriesPartialOptionsMap[TSeries],
): void {
this.lwcSeries.applyOptions(options);
}
public attachPrimitive(
primitive: ISeriesPrimitive<Time>,
): void {
this.lwcSeries.attachPrimitive(
primitive,
);
}
public barsInLogicalRange(
range: IRange<number>,
): BarsInfo<Time> | null {
return this.lwcSeries
.barsInLogicalRange(range);
}
public coordinateToPrice(
coordinate: number,
): BarPrice | null {
return this.lwcSeries
.coordinateToPrice(coordinate);
}
public createPriceLine(
options: CreatePriceLineOptions,
): IPriceLine {
return this.lwcSeries
.createPriceLine(options);
}
public data(): readonly SeriesDataItemTypeMap<
Time
>[TSeries][] {
return this.lwcSeries.data();
}
public dataByIndex(
logicalIndex: number,
mismatchDirection?:
MismatchDirection,
): SeriesDataItemTypeMap<
Time
>[TSeries] | null {
return this.lwcSeries.dataByIndex(
logicalIndex,
mismatchDirection,
);
}
public detachPrimitive(
primitive: ISeriesPrimitive<Time>,
): void {
this.lwcSeries.detachPrimitive(
primitive,
);
}
public getPane(): IPaneApi<Time> {
return this.lwcSeries.getPane();
}
public moveToPane(
paneIndex: number,
): void {
this.lwcSeries.moveToPane(
paneIndex,
);
}
public options(): Readonly<
SeriesOptionsMap[TSeries]
> {
return this.lwcSeries.options();
}
public priceFormatter():
IPriceFormatter {
return this.lwcSeries
.priceFormatter();
}
public priceLines(): IPriceLine[] {
return this.lwcSeries.priceLines();
}
public priceScale(): IPriceScaleApi {
return this.lwcSeries.priceScale();
}
public priceToCoordinate(
price: number,
): Coordinate | null {
return this.lwcSeries
.priceToCoordinate(price);
}
public removePriceLine(
line: IPriceLine,
): void {
this.lwcSeries.removePriceLine(
line,
);
}
public seriesOrder(): number {
return this.lwcSeries.seriesOrder();
}
public seriesType(): TSeries {
return this.lwcSeries.seriesType();
}
public setData(
data: SeriesDataItemTypeMap<
Time
>[TSeries][],
): void {
const normalizedData =
normalizeSeriesData(data);
this.lwcSeries.setData(
normalizedData,
);
}
public setSeriesOrder(
order: number,
): void {
this.lwcSeries.setSeriesOrder(
order,
);
}
public subscribeDataChanged(
handler: DataChangedHandler,
): void {
this.lwcSeries.subscribeDataChanged(
handler,
);
}
public unsubscribeDataChanged(
handler: DataChangedHandler,
): void {
this.lwcSeries
.unsubscribeDataChanged(handler);
}
public update(
bar: SeriesDataItemTypeMap<
Time
>[TSeries],
historicalUpdate?: boolean,
): void {
const data =
this.lwcSeries.data();
const last =
data.length > 0
? data[data.length - 1]
: null;
if (!last) {
this.lwcSeries.update(
bar,
false,
);
return;
}
const lastTime = last.time;
const nextTime = bar.time;
const isHistoricalUpdate =
historicalUpdate ??
(typeof lastTime === 'number' &&
typeof nextTime === 'number' &&
nextTime < lastTime);
this.lwcSeries.update(
bar,
isHistoricalUpdate,
);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>):
ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const defaultOptions =
this.getDefaultOptions();
const mergedOptions = {
...defaultOptions,
...seriesOptions,
};
const series =
chart.addSeries<TSeries>(
this.seriesDefinition(),
mergedOptions,
paneIndex,
);
chart
.priceScale(
mergedOptions.priceScaleId ??
Direction.Right,
paneIndex,
)
.applyOptions(
priceScaleOptions,
);
return series;
}
protected abstract dataSourceSubscription(
next: Candle[],
): void;
protected abstract seriesDefinition():
SeriesDefinition<TSeries>;
protected abstract dataSourceRealtimeSubscription(
next: Candle,
): void;
protected abstract getDefaultOptions():
SeriesPartialOptionsMap[TSeries];
protected abstract formatMainSerie(
inputData: Candle[],
): SeriesDataItemTypeMap<
Time
>[TSeries][];
protected abstract 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;
}
>
>;
protected applyTimezone(
data: Candle[],
): Candle[] {
return applyMoscowTimezone(data);
}
protected formatData(
inputData: Candle[],
): SeriesDataItemTypeMap<
Time
>[TSeries][] {
const formatter =
this.customFormatter;
const data =
this.applyTimezone(inputData);
if (formatter) {
const mainSeriesData =
(this.mainSerie$.value
?.data() ??
[]) as unknown as SerieData[];
if (data.length === 1) {
return formatter({
mainSeriesData,
selfData:
this.data() as unknown as ChartTypeToCandleData[TSeries][],
candle:
this.formatMainSerie(
data,
)[0] as unknown as SerieData,
indicatorReference:
this.indicatorReference ??
undefined,
});
}
return formatter({
mainSeriesData,
indicatorReference:
this.indicatorReference ??
undefined,
selfData:
this.data() as unknown as ChartTypeToCandleData[TSeries][],
});
}
return this.formatMainSerie(data);
}
protected subscribeDataSource = (
dataSource: DataSource,
): void => {
const minMove =
getPricePrecisionStep();
this.subscriptions.add(
this.mainSymbol$
.pipe(distinctUntilChanged())
.subscribe((symbol) => {
this.lwcSeries.applyOptions({
// todo: на каждый апдейт dataSource сеттим options. Не оптимально
title:
this.showSymbolLabel
? symbol
: '',
priceFormat: {
type: 'custom',
minMove,
formatter: (
price: number,
) =>
formatPrice(price) ??
String(price),
},
});
this.dataSub?.unsubscribe();
this.realtimeSub
?.unsubscribe();
this.dataSub =
dataSource.subscribe(
symbol,
(next) => {
this.dataSourceSubscription(
next,
);
},
);
this.realtimeSub =
dataSource
.subscribeRealtime(
symbol,
(next: Candle) => {
this.dataSourceRealtimeSubscription(
next,
);
},
);
}),
);
};
}
export function calcCandleChange(
prev:
| BarData
| LineData
| HistogramData
| CustomData
| null,
current:
| BarData
| LineData
| HistogramData
| CustomData
| null,
): (Ohlc & {
customValues?: Record<
string,
unknown
>;
}) | null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (
isBarData(prev) &&
isBarData(current)
) {
const absoluteChange =
current.close - prev.close;
const percentageChange =
((current.close - prev.close) /
prev.close) *
100;
return {
...current,
absoluteChange,
percentageChange,
};
}
if (
isLineData(prev) &&
isLineData(current)
) {
const absoluteChange =
current.value - prev.value;
const percentageChange =
((current.value - prev.value) /
prev.value) *
100;
return {
time: current.time,
value: current.value,
high:
current.customValues
?.high as number,
low:
current.customValues
?.low as number,
absoluteChange,
percentageChange,
customValues:
current.customValues,
};
}
return null;
}