Загрузка данных


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,
  getTimeFromXCoordinate,
  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?.sourceEvent) {
      this.applyInitialPoint(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

  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 [this.startTimeAxisView];
    }

    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.isSelected() && !this.isCreationPending()) {
      return null;
    }

    if (this.profileKind === 'visibleRange') {
      if (kind !== 'start') {
        return null;
      }

      const geometry = this.getGeometry();

      if (!geometry) {
        return null;
      }

      const time = getTimeFromXCoordinate(this.chart, geometry.left);

      if (typeof time !== 'number') {
        return null;
      }

      return this.createTimeAxisLabel(geometry.left, time);
    }

    if (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;
    }

    return this.createTimeAxisLabel(Number(coordinate), anchor.time);
  }

  private createTimeAxisLabel(coordinate: number, time: number): AxisLabel {
    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        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 applyInitialPoint(point: Point): void {
    if (this.profileKind === 'visibleRange') {
      this.moveVisibleRangeStart(point);
      this.calculateProfile();
      this.render();
      return;
    }

    this.startDrawing(point);
  }

  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 { MouseEventParams, 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('использует initialEvent как начало profile и остаётся в ready', () => {
    const env = createDrawingTestEnvironment({
      selected: false,
      seriesData: candles,
    });
    const sourceEvent = createPointerEvent(env.container, 200, 20);

    const profile = new TestVolumeProfile({
      ...env,
      profileKind: 'visibleRange',
      initialEvent: { sourceEvent } as unknown as MouseEventParams,
    });

    expect(profile.getState()).toMatchObject({
      mode: 'ready',
      visibleRangeStartRatio: 0.5,
    });
    expect(profile.isCreationPending()).toBe(false);
    expect(profile.getRenderData()).toMatchObject({
      left: 200,
      right: 400,
      width: 200,
      profileKind: 'visibleRange',
    });
  });

  it('отображается в Object Tree', () => {
    const { profile } = createProfile('visibleRange');

    expect(profile.shouldShowInObjectTree()).toBe(true);
  });

  it('показывает время anchor и цену POC на шкалах', () => {
    const { profile } = createProfile('visibleRange', true);

    profile.setState({ visibleRangeStartRatio: 0.5 });

    expect(profile.timeAxisPaneViews()).toEqual([]);
    expect(profile.priceAxisPaneViews()).toEqual([]);
    expect(profile.timeAxisViews()).toHaveLength(1);
    expect(profile.priceAxisViews()).toHaveLength(1);

    expect(profile.timeLabel('start')).toMatchObject({
      coordinate: 200,
      text: 'date:200',
    });
    expect(profile.timeLabel('end')).toBeNull();

    const pocY = profile.getRenderData()?.pocY;

    expect(pocY).not.toBeNull();
    expect(profile.priceLabel('poc')).toMatchObject({
      coordinate: pocY,
      text: `price:${pocY}`,
    });
    expect(profile.priceLabel('start')).toBeNull();
  });

  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);
    expect(profile.timeLabel('start')).toMatchObject({
      coordinate: 300,
      text: 'date:300',
    });
  });

  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']);
  });
});