Загрузка данных
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { positionsBox } from '@core/Drawings/common';
import type { VolumeProfileRendererData } from './volumeProfile';
export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
_data: VolumeProfileRendererData;
constructor(data: VolumeProfileRendererData) {
this._data = data;
}
public draw(target: CanvasRenderingTarget2D): void {
target.useBitmapCoordinateSpace((scope) => {
if (this._data.x === null || this._data.top === null) {
return;
}
const ctx = scope.context;
const horizontalPositions = positionsBox(
this._data.x,
this._data.x + this._data.width,
scope.horizontalPixelRatio,
);
const verticalPositions = positionsBox(
this._data.top,
this._data.top - this._data.columnHeight * this._data.items.length,
scope.verticalPixelRatio,
);
ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
ctx.fillRect(
horizontalPositions.position,
verticalPositions.position,
horizontalPositions.length,
verticalPositions.length,
);
ctx.fillStyle = 'rgba(80, 80, 255, 0.8)';
this._data.items.forEach((row) => {
if (row.y === null) {
return;
}
const itemVerticalPositions = positionsBox(row.y, row.y - this._data.columnHeight, scope.verticalPixelRatio);
const itemHorizontalPositions = positionsBox(
this._data.x!,
this._data.x! + row.width,
scope.horizontalPixelRatio,
);
ctx.fillRect(
itemHorizontalPositions.position,
itemVerticalPositions.position,
itemHorizontalPositions.length,
itemVerticalPositions.length - 2,
);
});
});
}
}
import { Coordinate, IPrimitivePaneRenderer, IPrimitivePaneView } from 'lightweight-charts';
import { getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { VolumeProfilePaneRenderer } from './paneRenderer';
import type { VolumeProfile, VolumeProfileItem, VolumeProfileRendererData } from './volumeProfile';
export class VolumeProfilePaneView implements IPrimitivePaneView {
_source: VolumeProfile;
_x: Coordinate | null = null;
_width = 6;
_columnHeight = 0;
_top: Coordinate | null = null;
_items: VolumeProfileItem[] = [];
constructor(source: VolumeProfile) {
this._source = source;
}
public update(): void {
if (!this._source._vpData || !this._source._p1 || !this._source._p2) {
return;
}
const data = this._source._vpData;
this._x = getXCoordinateFromTime(this._source._chart, data.time);
const left = getXCoordinateFromTime(
this._source._chart,
Math.max(Number(this._source._p1.time), Number(this._source._p2.time)) as typeof data.time,
);
const right = getXCoordinateFromTime(
this._source._chart,
Math.min(Number(this._source._p1.time), Number(this._source._p2.time)) as typeof data.time,
);
if (left === null || right === null) {
return;
}
this._width = Math.abs(left - right);
const firstPrice = data.profile[0]?.price ?? this._source._minPrice;
const secondPrice = data.profile[1]?.price ?? firstPrice;
const y1 = getYCoordinateFromPrice(this._source._series, this._source._minPrice);
const y2 = getYCoordinateFromPrice(this._source._series, secondPrice);
if (y1 === null || y2 === null) {
return;
}
this._columnHeight = Math.max(1, Math.abs(y1 - y2));
this._top = y1;
const maxVolume = data.profile.reduce((max, item) => Math.max(max, item.vol), 0);
this._items = data.profile.map((row) => ({
y: getYCoordinateFromPrice(this._source._series, row.price),
width: maxVolume === 0 ? 0 : (this._width * row.vol) / maxVolume,
}));
}
public renderer(): IPrimitivePaneRenderer {
const rendererData: VolumeProfileRendererData = {
x: this._x,
top: this._top,
columnHeight: this._columnHeight,
width: this._width,
items: this._items,
};
return new VolumeProfilePaneRenderer(rendererData);
}
}
import {
AutoscaleInfo,
Coordinate,
IChartApi,
ISeriesApi,
Logical,
LogicalRange,
MouseEventParams,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { ISeriesDrawing, Point } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getTimeFromXCoordinate, getXCoordinateFromTime } from '@core/Drawings/helpers';
import { VolumeProfilePaneView } from './paneView';
interface SeriesCandleData {
time: Time;
high?: number;
low?: number;
close?: number;
value?: number;
customValues?: {
volume?: number;
};
}
export interface VolumeProfileItem {
y: Coordinate | null;
width: number;
}
export interface VolumeProfileRendererData {
x: Coordinate | null;
top: Coordinate | null;
columnHeight: number;
width: number;
items: VolumeProfileItem[];
}
export interface VolumeProfileDataPoint {
price: number;
vol: number;
}
export interface VolumeProfileData {
time: Time;
profile: VolumeProfileDataPoint[];
width: number;
}
const PROFILE_ROW_COUNT = 15;
export class VolumeProfile implements ISeriesDrawing {
private _attached = false;
_chart: IChartApi;
_series: ISeriesApi<keyof SeriesOptionsMap>;
_vpData!: VolumeProfileData;
_minPrice!: number;
_maxPrice!: number;
_paneViews: VolumeProfilePaneView[];
_visible = true;
_p1!: Point;
_p2!: Point;
constructor(chart: IChartApi, series: ISeriesApi<SeriesType>) {
this._chart = chart;
this._series = series;
this._chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this._chart.subscribeClick(this.startDrawing);
this._paneViews = [new VolumeProfilePaneView(this)];
}
private handleVisibleLogicalRangeChange = (logicalRange: LogicalRange | null) => {
if (!logicalRange) {
return;
}
if (!this._p1 || !this._p2 || !this._attached) {
return;
}
this.calcProfile();
this.updateAllViews();
};
private startDrawing = (param: MouseEventParams<Time>) => {
this._chart.unsubscribeClick(this.startDrawing);
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
this._p1 = { time, price };
this._p2 = { time, price };
this.calcProfile();
this._series.attachPrimitive(this);
this._attached = true;
this._chart.subscribeClick(this.endDrawing);
this._chart.subscribeCrosshairMove(this.drawing);
this.updateAllViews();
};
private drawing = (param: MouseEventParams<Time>) => {
if (!param.point) {
return;
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
return;
}
this._p2 = { time, price };
this._minPrice = Math.min(this._p1.price, this._p2.price);
this._maxPrice = Math.max(this._p1.price, this._p2.price);
this.calcProfile();
this.updateAllViews();
};
private endDrawing = (param: MouseEventParams<Time>) => {
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
this._p2 = { time, price };
this._minPrice = Math.min(this._p1.price, this._p2.price);
this._maxPrice = Math.max(this._p1.price, this._p2.price);
this.calcProfile();
this._chart.unsubscribeCrosshairMove(this.drawing);
this._chart.unsubscribeClick(this.endDrawing);
this.updateAllViews();
};
private calcProfile = () => {
if (!this._p1 || !this._p2) {
return;
}
const leftFrameTime = Math.min(Number(this._p1.time), Number(this._p2.time));
const rightFrameTime = Math.max(Number(this._p1.time), Number(this._p2.time));
const candles = this._series.data() as SeriesCandleData[];
const selectedCandles: SeriesCandleData[] = [];
let maxFramePrice = Math.max(this._p1.price, this._p2.price);
let minFramePrice = Math.min(this._p1.price, this._p2.price);
candles.forEach((candle) => {
const candleTime = Number(candle.time);
if (candleTime > leftFrameTime && candleTime < rightFrameTime) {
const candlePrice = candle.close ?? candle.value ?? 0;
maxFramePrice = Math.max(candlePrice, maxFramePrice);
minFramePrice = Math.min(candlePrice, minFramePrice);
selectedCandles.push(candle);
}
});
if (maxFramePrice === minFramePrice) {
maxFramePrice = minFramePrice + Math.max(Math.abs(minFramePrice) * 0.001, 1);
}
this._minPrice = minFramePrice;
this._maxPrice = maxFramePrice;
const priceRange = this._maxPrice - this._minPrice;
const priceStep = priceRange / PROFILE_ROW_COUNT;
const profile: VolumeProfileDataPoint[] = [];
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
profile.push({
price: this._minPrice + index * priceStep,
vol: 0,
});
}
selectedCandles.forEach((candle) => {
const candleVolume = candle.customValues?.volume ?? 0;
const candleHigh = candle.high ?? candle.close ?? candle.value ?? this._maxPrice;
const candleLow = candle.low ?? candle.close ?? candle.value ?? this._minPrice;
const isCandleOutOfRange = candleHigh < this._minPrice || candleLow > this._maxPrice;
if (isCandleOutOfRange) {
return;
}
const topPriceInRange = Math.min(this._maxPrice, candleHigh);
const lowPriceInRange = Math.max(this._minPrice, candleLow);
const inRangePartLength = topPriceInRange - lowPriceInRange;
const candleRange = candleHigh - candleLow;
const safeCandleRange = candleRange === 0 ? priceStep : candleRange;
const inRangeVolume = candleVolume * (inRangePartLength / safeCandleRange);
const parts = Math.max(1, Math.ceil((inRangePartLength / priceRange) * PROFILE_ROW_COUNT));
const volumePerPart = inRangeVolume / parts;
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
const lowPrice = this._minPrice + priceStep * index;
const highPrice = lowPrice + priceStep;
const isInRange = !(lowPrice > topPriceInRange || highPrice < lowPriceInRange);
if (isInRange) {
profile[index].vol += volumePerPart;
}
}
});
this._vpData = {
time: Math.min(Number(this._p2.time), Number(this._p1.time)) as Time,
profile,
width: 10,
};
};
public updateAllViews() {
this._paneViews.forEach((pw) => pw.update());
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
if (!this._vpData) {
return null;
}
const vpCoordinate = getXCoordinateFromTime(this._chart, this._vpData.time);
if (vpCoordinate === null) {
return null;
}
const vpIndex = this._chart.timeScale().coordinateToLogical(vpCoordinate);
if (vpIndex === null) {
return null;
}
if (endTimePoint < vpIndex || startTimePoint > vpIndex + this._vpData.width) {
return null;
}
return {
priceRange: {
minValue: this._minPrice,
maxValue: this._maxPrice,
},
};
}
public paneViews() {
return this._paneViews;
}
public hide(): void {
this._visible = false;
this.updateAllViews();
}
public show(): void {
this._visible = true;
this.updateAllViews();
}
public rebind(series: ISeriesApi<SeriesType>): void {
if (this._attached) {
this._series.detachPrimitive(this);
}
this._series = series;
this.updateAllViews();
if (this._attached) {
this._series.attachPrimitive(this);
}
}
public destroy(): void {
this._chart.unsubscribeClick(this.startDrawing);
this._chart.unsubscribeClick(this.endDrawing);
this._chart.unsubscribeCrosshairMove(this.drawing);
this._chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
if (this._attached) {
this._series.detachPrimitive(this);
this._attached = false;
}
}
}