Загрузка данных
import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { VolumeProfilePaneView } from './paneView';
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
VolumeProfileSettings,
VolumeProfileStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel } from '@src/types';
export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
type VolumeProfileHandleId = Exclude<DragTarget, 'body' | null>;
interface VolumeProfileParams extends BaseDrawingParams {
profileKind?: VolumeProfileKind;
}
interface SeriesCandleData {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
customValues?: {
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
};
}
export interface VolumeProfileState {
hidden: boolean;
mode: VolumeProfileMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
visibleRangeStartRatio: number;
settings: VolumeProfileSettings;
}
interface VolumeProfileDataRow {
priceLow: number;
priceHigh: number;
buyVolume: number;
sellVolume: number;
totalVolume: number;
}
interface VolumeProfileGeometry extends Bounds {
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
interface VolumeProfileRenderRow {
top: number;
height: number;
buyWidth: number;
sellWidth: number;
}
export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumeProfileStyle {
profileKind: VolumeProfileKind;
rows: VolumeProfileRenderRow[];
pocY: number | null;
}
const PROFILE_ROW_COUNT = 24;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
export class VolumeProfile
extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly profileKind: VolumeProfileKind;
protected settings: VolumeProfileSettings = createDefaultSettings();
protected mode: VolumeProfileMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private profileRows: VolumeProfileDataRow[] = [];
private profileMinPrice: number | null = null;
private profileMaxPrice: number | null = null;
private visibleRangeStartRatio = 0;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: VolumeProfileGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: VolumeProfilePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
profileKind = 'fixedRange',
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: VolumeProfileParams) {
super({ chart, series, container, interaction });
this.profileKind = profileKind;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
}
this.paneView = new VolumeProfilePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.calculateProfile();
}
this.series.attachPrimitive(this);
if (initialEvent && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public destroy(): void {
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
}
super.destroy();
}
public isCreationPending(): boolean {
if (this.profileKind === 'visibleRange') {
return false;
}
return this.mode === 'idle' || this.mode === 'drawing';
}
public shouldShowInObjectTree(): boolean {
if (this.profileKind === 'visibleRange') {
return true;
}
return super.shouldShowInObjectTree();
}
public getState(): VolumeProfileState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
visibleRangeStartRatio: this.visibleRangeStartRatio,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<VolumeProfileState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (this.profileKind === 'fixedRange') {
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
}
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
this.resolveReady?.();
if (typeof nextState.visibleRangeStartRatio === 'number') {
this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.calculateProfile();
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getVolumeProfileSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.priceAxisPaneView];
}
public timeAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): VolumeProfileRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const { rows, pocY } = this.getProfileRenderRows(geometry);
return {
...geometry,
profileKind: this.profileKind,
rows,
pocY,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<VolumeProfileHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
if (this.profileKind === 'visibleRange') {
const pocY = this.getPocY(geometry);
if (pocY === null) {
return [];
}
return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle' }];
}
return [
{ id: 'end', ...geometry.endPoint, shape: 'circle' },
{ id: 'start', ...geometry.startPoint, shape: 'circle' },
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const dragTarget = this.getDragTarget({ x, y });
if (!dragTarget) {
return null;
}
if (dragTarget === 'poc') {
return {
cursorStyle: 'ew-resize',
externalId: 'volume-profile',
zOrder: 'top',
};
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'volume-profile',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
// const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;
if (!price) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private handleVisibleLogicalRangeChange = (): void => {
if (this.profileKind !== 'visibleRange') {
return;
}
this.calculateProfile();
this.render();
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDragTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerDown(event, point);
return;
}
this.handleFixedRangePointerDown(event, point);
};
protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
let dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
if (this.isSelected()) {
this.deselect();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.isSelected()) {
this.select();
dragTarget = this.getVisibleRangeDragTarget(point);
}
if (dragTarget !== 'poc') {
return;
}
this.activeDragTarget = 'poc';
this.dragPointerId = event.pointerId;
this.mode = 'dragging';
this.hideCrosshair();
this.render();
}
private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const dragTarget = this.getFixedRangeDragTarget(point);
if (!this.isSelected()) {
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
}
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerMove(event, point);
return;
}
this.handleFixedRangePointerMove(event, point);
};
private handleVisibleRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || this.activeDragTarget !== 'poc') {
return;
}
event.preventDefault();
this.moveVisibleRangeStart(point);
this.calculateProfile();
this.render();
}
private handleFixedRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveBody(point);
this.render();
return;
}
this.moveHandle(point);
this.render();
}
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.calculateProfile();
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.calculateProfile();
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
this.profileKind === 'fixedRange' &&
(!geometry || geometry.width < MIN_PROFILE_SIZE || geometry.height < MIN_PROFILE_SIZE)
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private moveVisibleRangeStart(point: Point): void {
const { width } = this.getContainerSize();
if (width <= 0) {
return;
}
this.visibleRangeStartRatio = clamp(point.x / width, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
private moveHandle(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body' || this.activeDragTarget === 'poc') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startAnchor = anchor;
}
if (this.activeDragTarget === 'end') {
this.endAnchor = anchor;
}
this.calculateProfile();
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
this.setAnchorsFromPoints(
{
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
},
{
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
},
);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(this.clampPointToContainer(startPoint));
const endAnchor = this.createAnchor(this.clampPointToContainer(endPoint));
if (!startAnchor || !endAnchor) {
return;
}
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
this.calculateProfile();
}
private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
if (this.profileKind === 'visibleRange') {
return this.getVisibleRangeDragTarget(point);
}
return this.getFixedRangeDragTarget(point);
}
private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const handle = this.getDrawingHandleAtPoint(point);
if (handle?.id === 'poc') {
return 'poc';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const handle = this.getDrawingHandleAtPoint(point);
if (handle) {
return handle.id;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private calculateProfile(): void {
if (this.profileKind === 'visibleRange') {
this.calculateAnchoredVolumeProfile();
return;
}
this.calculateFixedRangeVolumeProfile();
}
private calculateAnchoredVolumeProfile(): void {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) {
this.clearProfile();
return;
}
const from = Number(visibleRange.from);
const to = Number(visibleRange.to);
const start = from + (to - from) * this.visibleRangeStartRatio;
this.calculateProfileByLogicalRange(start, to);
}
private calculateFixedRangeVolumeProfile(): void {
if (!this.startAnchor || !this.endAnchor) {
this.clearProfile();
return;
}
const leftFrameTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
const rightFrameTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));
const candles = this.series.data() as SeriesCandleData[];
const selectedCandles: SeriesCandleData[] = [];
candles.forEach((candle) => {
const candleTime = Number(candle.time);
if (candleTime >= leftFrameTime && candleTime <= rightFrameTime) {
selectedCandles.push(candle);
}
});
this.calculateProfileByCandles(selectedCandles);
}
private calculateProfileByLogicalRange(fromLogical: number, toLogical: number): void {
const candles = this.series.data() as SeriesCandleData[];
if (!candles.length) {
this.clearProfile();
return;
}
const fromIndex = Math.max(0, Math.floor(Math.min(fromLogical, toLogical)));
const toIndex = Math.min(candles.length - 1, Math.ceil(Math.max(fromLogical, toLogical)));
if (fromIndex > toIndex) {
this.clearProfile();
return;
}
this.calculateProfileByCandles(candles.slice(fromIndex, toIndex + 1));
}
private calculateProfileByCandles(candles: SeriesCandleData[], fixedMinPrice?: number, fixedMaxPrice?: number): void {
if (!candles.length && (fixedMinPrice === undefined || fixedMaxPrice === undefined)) {
this.clearProfile();
return;
}
let minPrice = fixedMinPrice ?? Infinity;
let maxPrice = fixedMaxPrice ?? -Infinity;
if (fixedMinPrice === undefined || fixedMaxPrice === undefined) {
candles.forEach((candle) => {
const price = this.getCandlePrice(candle);
if (price === null) {
return;
}
minPrice = Math.min(minPrice, this.getCandleLow(candle, price));
maxPrice = Math.max(maxPrice, this.getCandleHigh(candle, price));
});
}
if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
this.clearProfile();
return;
}
if (minPrice === maxPrice) {
maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
}
this.profileMinPrice = minPrice;
this.profileMaxPrice = maxPrice;
const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
const profileRows = this.createEmptyProfileRows(minPrice, priceStep);
candles.forEach((candle) => {
const volume = this.getCandleVolume(candle);
if (volume <= 0) {
return;
}
const price = this.getCandlePrice(candle);
if (price === null) {
return;
}
const candleHigh = this.getCandleHigh(candle, price);
const candleLow = this.getCandleLow(candle, price);
if (candleHigh < minPrice || candleLow > maxPrice) {
return;
}
const isBuyVolume = this.isBuyVolume(candle);
if (candleHigh === candleLow) {
addPointVolume(profileRows, candleHigh, volume, isBuyVolume);
return;
}
const highInRange = Math.min(maxPrice, candleHigh);
const lowInRange = Math.max(minPrice, candleLow);
const candleRange = candleHigh - candleLow;
profileRows.forEach((row) => {
const overlap = Math.max(0, Math.min(row.priceHigh, highInRange) - Math.max(row.priceLow, lowInRange));
if (overlap <= 0) {
return;
}
addVolumeToRow(row, volume * (overlap / candleRange), isBuyVolume);
});
});
this.profileRows = profileRows;
}
private createEmptyProfileRows(minPrice: number, priceStep: number): VolumeProfileDataRow[] {
const rows: VolumeProfileDataRow[] = [];
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
rows.push({
priceLow: minPrice + priceStep * index,
priceHigh: minPrice + priceStep * (index + 1),
buyVolume: 0,
sellVolume: 0,
totalVolume: 0,
});
}
return rows;
}
private clearProfile(): void {
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
}
private getCandleVolume(candle: SeriesCandleData): number {
return this.getNumber(candle.customValues?.volume) ?? this.getNumber(candle.volume) ?? 0;
}
private getCandlePrice(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleOpen(candle: SeriesCandleData): number | null {
return this.getNumber(candle.open) ?? this.getNumber(candle.customValues?.open);
}
private getCandleClose(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleHigh(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.high) ?? this.getNumber(candle.customValues?.high) ?? fallbackPrice;
}
private getCandleLow(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.low) ?? this.getNumber(candle.customValues?.low) ?? fallbackPrice;
}
private isBuyVolume(candle: SeriesCandleData): boolean {
const open = this.getCandleOpen(candle);
const close = this.getCandleClose(candle);
if (open === null || close === null) {
return true;
}
return close >= open;
}
private getNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
protected getGeometry(): VolumeProfileGeometry | null {
if (this.profileKind === 'visibleRange') {
return this.getAnchoredVolumeProfileGeometry();
}
return this.getFixedRangeVolumeProfileGeometry();
}
private getAnchoredVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
const { width, height } = this.getContainerSize();
const topCoordinate = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
const bottomCoordinate = getYCoordinateFromPrice(this.series, this.profileMinPrice);
if (topCoordinate === null || bottomCoordinate === null) {
return null;
}
const left = clamp(Math.round(width * this.visibleRangeStartRatio), 0, width);
const right = width;
const top = clamp(Math.round(Math.min(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
const bottom = clamp(Math.round(Math.max(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
return {
startPoint: {
x: left,
y: top,
},
endPoint: {
x: right,
y: bottom,
},
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getFixedRangeVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.profileMinPrice);
const endY = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.min(startPoint.x, endPoint.x);
const right = Math.max(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
const bottom = Math.max(startPoint.y, endPoint.y);
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getPocY(geometry: VolumeProfileGeometry): number | null {
return this.getProfileRenderRows(geometry).pocY;
}
private getProfileRenderRows(geometry: VolumeProfileGeometry): {
rows: VolumeProfileRenderRow[];
pocY: number | null;
} {
if (!this.profileRows.length) {
return {
rows: [],
pocY: null,
};
}
const maxVolume = this.profileRows.reduce((max, row) => Math.max(max, row.totalVolume), 0);
if (maxVolume <= 0) {
return {
rows: [],
pocY: null,
};
}
let pocY: number | null = null;
let pocVolume = 0;
const rows = this.profileRows
.map((row) => {
const highY = getYCoordinateFromPrice(this.series, row.priceHigh);
const lowY = getYCoordinateFromPrice(this.series, row.priceLow);
if (highY === null || lowY === null) {
return null;
}
const top = clamp(Math.min(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
const bottom = clamp(Math.max(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
if (row.totalVolume > pocVolume) {
pocVolume = row.totalVolume;
pocY = (top + bottom) / 2;
}
return {
top,
height: Math.max(1, bottom - top),
buyWidth: (geometry.width * row.buyVolume) / maxVolume,
sellWidth: (geometry.width * row.sellVolume) / maxVolume,
};
})
.filter((row): row is VolumeProfileRenderRow => row !== null);
return {
rows,
pocY,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
function addPointVolume(
profileRows: VolumeProfileDataRow[],
price: number,
volume: number,
isBuyVolume: boolean,
): void {
const row = profileRows.find((item, index) => {
const isLastRow = index === profileRows.length - 1;
return price >= item.priceLow && (price < item.priceHigh || isLastRow);
});
if (!row) {
return;
}
addVolumeToRow(row, volume, isBuyVolume);
}
function addVolumeToRow(row: VolumeProfileDataRow, volume: number, isBuyVolume: boolean): void {
if (isBuyVolume) {
row.buyVolume += volume;
} else {
row.sellVolume += volume;
}
row.totalVolume += volume;
}
import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
import { createDefaultSettings, getVolumeProfileSettingsTabs } from '@core/Drawings/volumeProfile/settings';
import { VolumeProfile, type VolumeProfileRenderData } from '@core/Drawings/volumeProfile/volumeProfile';
import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
volumeProfileAreaFill: '#11111122',
volumeProfileBuyFill: '#00aa00',
volumeProfileSellFill: '#aa0000',
volumeProfilePocLine: '#ffaa00',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
const candles = [
{ time: 10 as Time, open: 10, high: 14, low: 9, close: 12, volume: 100 },
{ time: 20 as Time, open: 12, high: 16, low: 10, close: 11, volume: 50 },
{ time: 30 as Time, open: 11, high: 25, low: 10, close: 20, customValues: { volume: 200 } },
{ time: 40 as Time, open: 20, high: 31, low: 18, close: 30, volume: 150 },
];
class TestVolumeProfile extends VolumeProfile {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createProfile(profileKind: 'fixedRange' | 'visibleRange' = 'fixedRange', selected = false) {
const env = createDrawingTestEnvironment({ selected, seriesData: candles });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const profile = new TestVolumeProfile({
...env,
profileKind,
removeSelf,
openSettings,
});
return { ...env, profile, removeSelf, openSettings };
}
function setFixedReady(profile: TestVolumeProfile): void {
profile.setState({
mode: 'ready',
startAnchor: { time: 10 as Time, price: 10 },
endAnchor: { time: 40 as Time, price: 30 },
});
}
describe('VolumeProfile fixedRange', () => {
it('является creation pending до завершения', () => {
const { profile } = createProfile();
expect(profile.isCreationPending()).toBe(true);
});
it('рассчитывает 24 profile rows', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()?.rows).toHaveLength(24);
});
it('рассчитывает min/max по high и low candles', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()).toMatchObject({
left: 10,
right: 40,
top: 9,
bottom: 31,
});
});
it('использует customValues.volume раньше обычного volume', () => {
const { profile } = createProfile();
setFixedReady(profile);
const rows = profile.getRenderData()?.rows ?? [];
const hasVolume = rows.some(({ buyWidth, sellWidth }) => buyWidth > 0 || sellWidth > 0);
expect(hasVolume).toBe(true);
});
it('рассчитывает POC', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()?.pocY).not.toBeNull();
});
it('возвращает два edge handles', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.handles()).toEqual([
{ id: 'end', x: 40, y: 31, shape: 'circle' },
{ id: 'start', x: 10, y: 9, shape: 'circle' },
]);
});
it('возвращает selected axis segments', () => {
const { profile } = createProfile('fixedRange', true);
setFixedReady(profile);
expect(profile.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
expect(profile.priceSegments()).toEqual([{ from: 9, to: 31, color: '#2962ff44' }]);
});
it('возвращает axis labels', () => {
const { profile } = createProfile('fixedRange', true);
setFixedReady(profile);
expect(profile.timeLabel('start')).toMatchObject({
coordinate: 10,
text: 'date:10',
});
expect(profile.priceLabel('start')).toMatchObject({
coordinate: 9,
text: 'price:9',
});
expect(profile.priceLabel('end')).toMatchObject({
coordinate: 31,
text: 'price:31',
});
});
it('создаётся двумя pointer down', () => {
const { profile, container } = createProfile();
profile.pointerDown(createPointerEvent(container, 10, 10));
profile.pointerDown(createPointerEvent(container, 40, 30));
expect(profile.getState().mode).toBe('ready');
});
it('удаляет слишком маленький profile', () => {
const { profile, container, removeSelf } = createProfile();
profile.pointerDown(createPointerEvent(container, 10, 10));
profile.pointerDown(createPointerEvent(container, 14, 14));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('перемещает start handle', () => {
const { profile, container } = createProfile('fixedRange', true);
setFixedReady(profile);
profile.pointerDown(createPointerEvent(container, 10, 9));
profile.pointerMove(createPointerEvent(container, 20, 15));
profile.pointerUp(createPointerEvent(container, 20, 15));
expect(profile.getState().startAnchor).toEqual({
time: 20,
price: 15,
});
});
it('выбирает unselected profile', () => {
const { profile, container, interaction } = createProfile();
setFixedReady(profile);
profile.pointerDown(createPointerEvent(container, 25, 20));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('возвращает pointer/grab в зависимости от selection', () => {
const unselected = createProfile();
setFixedReady(unselected.profile);
expect(unselected.profile.hovered(25, 20)?.cursorStyle).toBe('pointer');
const selected = createProfile('fixedRange', true);
setFixedReady(selected.profile);
expect(selected.profile.hovered(25, 20)?.cursorStyle).toBe('grab');
});
});
describe('VolumeProfile visibleRange', () => {
it('сразу находится в ready', () => {
const { profile } = createProfile('visibleRange');
expect(profile.getState().mode).toBe('ready');
expect(profile.isCreationPending()).toBe(false);
});
it('отображается в Object Tree', () => {
const { profile } = createProfile('visibleRange');
expect(profile.shouldShowInObjectTree()).toBe(true);
});
it('не имеет axis views', () => {
const { profile } = createProfile('visibleRange');
expect(profile.timeAxisPaneViews()).toEqual([]);
expect(profile.priceAxisPaneViews()).toEqual([]);
expect(profile.timeAxisViews()).toEqual([]);
expect(profile.priceAxisViews()).toEqual([]);
});
it('clamp visibleRangeStartRatio до 0.95', () => {
const { profile } = createProfile('visibleRange');
profile.setState({ visibleRangeStartRatio: 2 });
expect(profile.getState().visibleRangeStartRatio).toBe(0.95);
});
it('меняет левую границу через ratio', () => {
const { profile } = createProfile('visibleRange');
profile.setState({ visibleRangeStartRatio: 0.5 });
expect(profile.getRenderData()).toMatchObject({
left: 200,
right: 400,
width: 200,
profileKind: 'visibleRange',
});
});
it('имеет только POC handle', () => {
const { profile, setSelected } = createProfile('visibleRange');
setSelected(true);
const handles = profile.handles();
expect(handles).toHaveLength(1);
expect(handles[0]).toMatchObject({
id: 'poc',
x: 0,
shape: 'circle',
});
});
it('POC handle имеет ew-resize cursor', () => {
const { profile, setSelected } = createProfile('visibleRange');
setSelected(true);
const [handle] = profile.handles();
expect(profile.hovered(handle.x, handle.y)?.cursorStyle).toBe('ew-resize');
});
it('перемещает начало visible profile через POC', () => {
const { profile, container, setSelected } = createProfile('visibleRange');
setSelected(true);
const [handle] = profile.handles();
profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
profile.pointerMove(createPointerEvent(container, 300, handle.y));
expect(profile.getState().visibleRangeStartRatio).toBe(0.75);
});
it('подписывается и отписывается от visible range', () => {
const { profile, timeScale } = createProfile('visibleRange');
expect(timeScale.subscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
profile.destroy();
expect(timeScale.unsubscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
});
});
describe('VolumeProfilePaneRenderer', () => {
it('не рисует без render data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => null,
} as unknown as VolumeProfile).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует background, rows и POC', () => {
const data: VolumeProfileRenderData = {
startPoint: { x: 10, y: 10 },
endPoint: { x: 100, y: 100 },
left: 10,
right: 100,
top: 10,
bottom: 100,
width: 90,
height: 90,
profileKind: 'fixedRange',
rows: [
{
top: 10,
height: 10,
buyWidth: 30,
sellWidth: 20,
},
],
pocY: 50,
areaFillColor: '#111',
buyFillColor: '#0a0',
sellFillColor: '#a00',
pocLineColor: '#fa0',
};
const { target, context } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => data,
} as unknown as VolumeProfile).draw(target);
expect(context.fillRect).toHaveBeenCalledTimes(3);
expect(context.stroke).toHaveBeenCalledTimes(1);
});
it('рисует visibleRange справа налево и POC на всю ширину canvas', () => {
const data: VolumeProfileRenderData = {
startPoint: { x: 200, y: 10 },
endPoint: { x: 400, y: 100 },
left: 200,
right: 400,
top: 10,
bottom: 100,
width: 200,
height: 90,
profileKind: 'visibleRange',
rows: [{ top: 10, height: 10, buyWidth: 30, sellWidth: 20 }],
pocY: 50,
areaFillColor: '#111',
buyFillColor: '#0a0',
sellFillColor: '#a00',
pocLineColor: '#fa0',
};
const { target, context } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => data,
} as unknown as VolumeProfile).draw(target);
expect(context.moveTo).toHaveBeenCalledWith(0, 100);
expect(context.lineTo).toHaveBeenCalledWith(800, 100);
});
});
describe('VolumeProfilePaneView', () => {
it('возвращает renderer и top', () => {
const view = new VolumeProfilePaneView({} as VolumeProfile);
expect(view.renderer()).toBeInstanceOf(VolumeProfilePaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('VolumeProfile settings', () => {
it('создаёт defaults', () => {
expect(createDefaultSettings()).toEqual({
areaFillColor: '#11111122',
buyFillColor: '#00aa00',
sellFillColor: '#aa0000',
pocLineColor: '#ffaa00',
});
});
it('имеет один style tab', () => {
expect(getVolumeProfileSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
});
});