Загрузка данных
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
AutoscaleInfo,
CandlestickData,
Coordinate,
IChartApi,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesApi,
Logical,
MouseEventParams,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { ISeriesDrawing, Point, positionsBox } from '@core/Drawings/common';
interface VolumeProfileItem {
y: Coordinate | null;
width: number;
}
interface VolumeProfileRendererData {
x: Coordinate | null;
top: Coordinate | null;
columnHeight: number;
width: number;
items: VolumeProfileItem[];
}
interface VolumeProfileDataPoint {
price: number;
vol: number;
}
export interface VolumeProfileData {
time: Time;
profile: VolumeProfileDataPoint[];
width: number;
}
class VolumeProfileRenderer implements IPrimitivePaneRenderer {
_data: VolumeProfileRendererData;
constructor(data: VolumeProfileRendererData) {
this._data = data;
}
draw(target: CanvasRenderingTarget2D) {
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 itemVerticalPos = positionsBox(row.y, row.y - this._data.columnHeight, scope.verticalPixelRatio);
const itemHorizontalPos = positionsBox(this._data.x!, this._data.x! + row.width, scope.horizontalPixelRatio);
ctx.fillRect(
itemHorizontalPos.position,
itemVerticalPos.position,
itemHorizontalPos.length,
itemVerticalPos.length - 2, // 1 to close gaps
);
});
});
}
}
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;
}
update() {
const data = this._source._vpData;
const series = this._source._series;
const timeScale = this._source._chart.timeScale();
this._x = timeScale.timeToCoordinate(data.time);
const left = timeScale.timeToCoordinate(
Math.max(<number>this._source._p1.time, <number>this._source._p2.time) as Time,
);
const right = timeScale.timeToCoordinate(
Math.min(<number>this._source._p1.time, <number>this._source._p2.time) as Time,
);
if (left === null || right === null) {
return;
}
this._width = left - right;
const y1 = series.priceToCoordinate(this._source._minPrice) ?? (0 as Coordinate);
const y2 = series.priceToCoordinate(data.profile[1].price) ?? (timeScale.height() as Coordinate);
this._columnHeight = Math.max(1, y1 - y2);
const maxVolume = data.profile.reduce((acc, item) => Math.max(acc, item.vol), 0);
this._top = y1;
this._items = data.profile.map((row) => ({
y: series.priceToCoordinate(row.price),
width: (this._width * row.vol) / maxVolume,
}));
}
renderer() {
return new VolumeProfileRenderer({
x: this._x,
top: this._top,
columnHeight: this._columnHeight,
width: this._width,
items: this._items,
});
}
}
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;
_visibleData: CandlestickData<Time>[] = []; // todo: неверный тип, мы должны уметь строить по разным типа серии
constructor(chart: IChartApi, series: ISeriesApi<SeriesType>) {
this._chart = chart;
this._series = series;
const logRange = this._chart.timeScale().getVisibleLogicalRange();
const f = Math.floor(logRange?.from ?? 0);
const t = Math.ceil(logRange?.to ?? 0);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._visibleData = this._series.data().slice(f, t + 1);
this._chart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => {
if (!logicalRange) return;
const from = Math.floor(logicalRange.from);
const to = Math.ceil(logicalRange.to);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._visibleData = this._series.data().slice(from, to + 1);
this.calcProfile();
});
chart.subscribeClick(this.startDrawing);
this._paneViews = [new VolumeProfilePaneView(this)];
}
private startDrawing = (param: MouseEventParams) => {
this._chart.unsubscribeClick(this.startDrawing);
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = this._series.coordinateToPrice(param.point.y);
const time = this._chart.timeScale().coordinateToTime(param.point.x);
if (!time || !price) {
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);
};
private drawing = (param: MouseEventParams) => {
if (!param.point) {
return;
}
const price = this._series.coordinateToPrice(param.point.y);
const time = this._chart.timeScale().coordinateToTime(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) => {
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = this._series.coordinateToPrice(param.point.y);
const time = this._chart.timeScale().coordinateToTime(param.point.x);
if (!time || !price) {
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);
};
private calcProfile = () => {
const leftFrameTime = Math.min(<number>this._p1.time, <number>this._p2.time);
const rightFrameTime = Math.max(<number>this._p1.time, <number>this._p2.time);
let maxFramePrice = 0;
let minFramePrice = Infinity;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data = [];
this._series.data().forEach((candle) => {
if (<number>candle.time < rightFrameTime && <number>candle.time > leftFrameTime) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
maxFramePrice = Math.max(candle.close ?? candle.value, maxFramePrice); // todo: обработать разные серии
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
minFramePrice = Math.min(candle.close ?? candle.value, minFramePrice); // todo: обработать разные серии
data.push(candle);
}
});
this._minPrice = minFramePrice;
this._maxPrice = maxFramePrice;
const priceStep = Math.abs(maxFramePrice - minFramePrice) / 15;
const profile: { price: number; vol: number }[] = [];
for (let i = 0; i < 15; i++) {
profile.push({
price: this._minPrice + i * priceStep,
vol: 0,
});
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
data.forEach((candle) => {
const candleVolume = (candle.customValues?.volume ?? 0) as number;
const isCandleOutOfRange = candle.high < this._minPrice || candle.low > this._maxPrice;
if (isCandleOutOfRange) return;
const topPriceEnhanced = Math.min(this._maxPrice, candle.high);
const lowPriceEnhanced = Math.max(this._minPrice, candle.low);
const inRangePartLength = topPriceEnhanced - lowPriceEnhanced;
const inRangeVolume = candleVolume * (inRangePartLength / (candle.high - candle.low));
const parts = Math.ceil((inRangePartLength / (this._maxPrice - this._minPrice)) * 15);
const volumeIterations = inRangeVolume / parts;
for (let i = 0; i < 15; i++) {
const lowPrice = this._minPrice + priceStep * i;
const highPrice = this._minPrice + priceStep * i + 1;
const isInRange = !(lowPrice > topPriceEnhanced || highPrice < lowPriceEnhanced);
if (isInRange) {
profile[i].vol += volumeIterations;
}
}
});
this._vpData = {
time: Math.min(<number>this._p2.time, <number>this._p1.time) as Time,
profile,
width: 10,
};
};
updateAllViews() {
this._paneViews.forEach((pw) => pw.update());
}
// Ensures that the VP is within autoScale
autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
// calculation of vpIndex could be remembered to reduce CPU usage
// and only recheck if the data is changed ('full' update).
const vpCoordinate = this._chart.timeScale().timeToCoordinate(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,
},
};
}
paneViews() {
return this._paneViews;
}
hide(): void {
this._visible = false;
}
show(): void {
this._visible = true;
}
rebind(series: ISeriesApi<SeriesType>): void {
if (this._attached) this._series.detachPrimitive(this);
this._series = series;
this.updateAllViews();
if (this._attached) this._series.attachPrimitive(this);
}
}