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


diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca8ee1c7095b492b95cf409c350a39afd5d9ff11..dde707217686db0c728730de6187682c76a96227 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # latest
 
+- Исправлено ограничение RR для короткой позиции - значение теперь можно уменьшать до 0
+- Исправлено перекрытие нижних активных точек короткой и длинной позиций подписями у границы графика
+
+# 0.1.24
+
 - Добавлено отдельное управление абсолютным и относительным изменением в легенде
 - Разделено отображение абсолютного и относительного изменения в тултипе
 
diff --git a/jest.config.ts b/jest.config.ts
index fffc6d0abb8f6d388d45b4c9960ed20ac462c0f0..a679886f216e6a9dd480a8040300cfde2b33628c 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,7 +1,6 @@
 import { readFileSync } from 'fs';
 
 import JSON5 from 'json5';
-
 import { pathsToModuleNameMapper } from 'ts-jest';
 
 import type { Config } from '@jest/types';
@@ -10,23 +9,34 @@ const fileContent = readFileSync('./tsconfig.json');
 const fileContentJSON = JSON5.parse(fileContent.toString());
 
 const config: Config.InitialOptions = {
-  // нужен пакет, который поддержит резолв scss модуля
-  // transform: {
-  //   "\\.scss$": "jest-sass",
-  // },
   clearMocks: true,
   resetMocks: true,
   restoreMocks: true,
-  preset: 'ts-jest',
-  transform: {
-    '^.+\\.tsx?$': 'ts-jest',
-  },
   testEnvironment: 'jsdom',
+  testRegex: '[/\\\\]src[/\\\\].*\\.(?:test|spec)\\.[jt]sx?$',
+  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
   moduleDirectories: ['node_modules', '<rootDir>'],
+  transform: {
+    '^.+\\.[cm]?[jt]sx?$': [
+      'babel-jest',
+      {
+        babelrc: false,
+        configFile: false,
+        presets: [
+          ['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
+          ['@babel/preset-react', { runtime: 'automatic' }],
+          ['@babel/preset-typescript', { allowDeclareFields: true }],
+        ],
+      },
+    ],
+  },
+  transformIgnorePatterns: [],
   moduleNameMapper: {
     ...pathsToModuleNameMapper(fileContentJSON.compilerOptions.paths),
+    '^lightweight-charts$': '<rootDir>/node_modules/lightweight-charts/dist/lightweight-charts.development.mjs',
     '^exchange-elements/v2$': '<rootDir>/node_modules/exchange-elements/dist/v2.cjs.js',
-    '\\.(css|scss|module.scss)$': '<rootDir>/__mocks__/styleMock.js',
+    '\\.module\\.(?:css|scss|sass)$': 'identity-obj-proxy',
+    '\\.(?:css|scss|sass)$': '<rootDir>/__mocks__/styleMock.cjs',
   },
   coverageDirectory: '<rootDir>/coverage',
   coverageReporters: ['text', 'lcov'],
diff --git a/jest.setup.js b/jest.setup.js
deleted file mode 100644
index 0fb9e24a87ab75469ad2ddd4a09d198317d47f5d..0000000000000000000000000000000000000000
--- a/jest.setup.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/no-extraneous-dependencies
-import '@testing-library/jest-dom';
\ No newline at end of file
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd03df87f06b2ae780781cc4db7d244b1afbc5ba
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,5 @@
+// eslint-disable-next-line import/no-extraneous-dependencies
+import '@testing-library/jest-dom';
+import { TextDecoder, TextEncoder } from 'util';
+
+Object.assign(global, { TextDecoder, TextEncoder });
diff --git a/package-lock.json b/package-lock.json
index facb10d082ff14e52350299b02d3c9d3c87585db..aaf6442834decd7a89feb8c0ef7b5fa355ba7db0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,7 @@
         "@typescript-eslint/eslint-plugin": "^5.52.0",
         "@typescript-eslint/parser": "^5.52.0",
         "autoprefixer": "^10.4.21",
+        "babel-jest": "^29.7.0",
         "babel-loader": "^8.3.0",
         "copy-webpack-plugin": "^11.0.0",
         "cross-env": "^7.0.3",
@@ -78,6 +79,7 @@
         "file-loader": "^6.2.0",
         "html-webpack-plugin": "^5.5.0",
         "husky": "^9.1.5",
+        "identity-obj-proxy": "^3.0.0",
         "jest": "^29.7.0",
         "jest-environment-jsdom": "^29.5.0",
         "json5": "^2.2.3",
@@ -12576,9 +12578,10 @@
       "license": "MIT"
     },
     "node_modules/@types/lodash": {
-      "version": "4.17.13",
-      "dev": true,
-      "license": "MIT"
+      "version": "4.17.24",
+      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha1-SuM0/GLA6RXKjtjjXcxtTuspIV8=",
+      "dev": true
     },
     "node_modules/@types/lodash-es": {
       "version": "4.17.12",
@@ -14606,8 +14609,9 @@
     },
     "node_modules/babel-jest": {
       "version": "29.7.0",
+      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/babel-jest/-/babel-jest-29.7.0.tgz",
+      "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "@jest/transform": "^29.7.0",
         "@types/babel__core": "^7.1.14",
@@ -20719,6 +20723,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/harmony-reflect": {
+      "version": "1.6.2",
+      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+      "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+      "dev": true
+    },
     "node_modules/has": {
       "version": "1.0.3",
       "dev": true,
@@ -21371,6 +21381,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/identity-obj-proxy": {
+      "version": "3.0.0",
+      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+      "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
+      "dev": true,
+      "dependencies": {
+        "harmony-reflect": "^1.4.6"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/ieee754": {
       "version": "1.2.1",
       "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/ieee754/-/ieee754-1.2.1.tgz",
@@ -25265,11 +25287,10 @@
       }
     },
     "node_modules/lodash": {
-      "version": "4.17.21",
-      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw=",
-      "dev": true,
-      "license": "MIT"
+      "version": "4.18.1",
+      "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha1-/ytmwfYybVlRPeJAe/iBQ5gSdxw=",
+      "dev": true
     },
     "node_modules/lodash-es": {
       "version": "4.17.21",
diff --git a/package.json b/package.json
index d13259a4ae9eb954f81a5fde9abe618aba4ee91e..594df0fc835f1a2c6a70f0b2b0ea50586fbaa9e3 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
     "@typescript-eslint/eslint-plugin": "^5.52.0",
     "@typescript-eslint/parser": "^5.52.0",
     "autoprefixer": "^10.4.21",
+    "babel-jest": "^29.7.0",
     "babel-loader": "^8.3.0",
     "copy-webpack-plugin": "^11.0.0",
     "cross-env": "^7.0.3",
@@ -105,6 +106,7 @@
     "file-loader": "^6.2.0",
     "html-webpack-plugin": "^5.5.0",
     "husky": "^9.1.5",
+    "identity-obj-proxy": "^3.0.0",
     "jest": "^29.7.0",
     "jest-environment-jsdom": "^29.5.0",
     "json5": "^2.2.3",
diff --git a/src/core/Drawings/SeriesDrawingBase.ts b/src/core/Drawings/SeriesDrawingBase.ts
index 83db7f869b3213725deed6e82b2a6fbab4749cb2..20d57ff0af50e013aab7fc2d8dd21d6568d4bd04 100644
--- a/src/core/Drawings/SeriesDrawingBase.ts
+++ b/src/core/Drawings/SeriesDrawingBase.ts
@@ -17,11 +17,14 @@ import {
 } from 'lightweight-charts';
 import { Observable, Subject, Subscription } from 'rxjs';
 
-import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
+import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
+import { getPointerPoint as getPointerPointFromEvent, getRawPointerPoint } from '@core/Drawings/helpers';
 import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
 
 import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
+
 export interface DrawingInteraction {
   selected$: Observable<boolean>;
   locked$: Observable<boolean>;
@@ -81,7 +84,11 @@ export interface BaseDrawingParams {
   initialEvent?: MouseEventParams;
 }
 
-export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
+export abstract class SeriesDrawingBase<
+  TSettings extends SettingsValues = SettingsValues,
+  THandleId extends string = string,
+> implements ISeriesDrawing
+{
   protected hidden = false;
   protected chart: IChartApi;
   protected series: SeriesApi;
@@ -93,6 +100,7 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
 
   private readonly interaction: DrawingInteraction;
   private readonly settingsSubject = new Subject<SettingsValues>();
+  private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
   private isInteractionBound = false;
 
   protected readyPromise: Promise<void> | null = null;
@@ -104,6 +112,14 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
     this.series = series;
     this.container = container;
     this.interaction = interaction;
+
+    this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
+      if (this.hidden || !this.shouldShowHandles()) {
+        return [];
+      }
+
+      return this.getDrawingHandles();
+    });
   }
 
   public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
@@ -183,11 +199,13 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
 
   public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
     this.requestUpdate = param.requestUpdate;
+    this.series.attachPrimitive(this.handlesPrimitive);
     this.bindInteraction();
     this.bindEvents();
   }
 
   public detached(): void {
+    this.series.detachPrimitive(this.handlesPrimitive);
     this.showCrosshair();
     this.unbindEvents();
     this.requestUpdate = null;
@@ -300,6 +318,14 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
     return !this.isLocked() && (this.isSelected() || this.isCreationPending());
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
+    return [];
+  }
+
+  protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
+    return this.handlesPrimitive.findHandle(point);
+  }
+
   protected render(): void {
     this.updateAllViews();
     this.requestUpdate?.();
@@ -389,6 +415,10 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
   }
 
   private isEventInside(event: MouseEvent): boolean {
-    return event.target instanceof Node && this.container.contains(event.target);
+    if (event.target instanceof Node && this.container.contains(event.target)) {
+      return true;
+    }
+
+    return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
   }
 }
diff --git a/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts b/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96c08569669d0bdd9a3a90e2d58071f4e42749b9
--- /dev/null
+++ b/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
@@ -0,0 +1,573 @@
+import { BehaviorSubject } from 'rxjs';
+
+import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
+import { SeriesDrawingBase } from '@core/Drawings/SeriesDrawingBase';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
+import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
+import type { SettingsTab, SettingsValues } from '@src/types';
+import type {
+  IChartApi,
+  IPrimitivePaneView,
+  ISeriesPrimitiveAxisView,
+  PrimitiveHoveredItem,
+  SeriesAttachedParameter,
+  Time,
+} from 'lightweight-charts';
+
+class TestDrawing extends SeriesDrawingBase<SettingsValues, 'main'> {
+  protected mode: 'idle' | 'ready' = 'ready';
+
+  protected settings: SettingsValues = {
+    color: '#ffffff',
+  };
+
+  public handlePoint: Point = {
+    x: 50,
+    y: 50,
+  };
+
+  public updateAllViews = jest.fn();
+
+  public pointerDownCalls = 0;
+  public clickCalls = 0;
+  public doubleClickCalls = 0;
+  public contextMenuCalls = 0;
+
+  public isCreationPending(): boolean {
+    return this.mode === 'idle';
+  }
+
+  public getState() {
+    return {
+      mode: this.mode,
+    };
+  }
+
+  public setState(state: unknown): void {
+    if (!state || typeof state !== 'object') {
+      return;
+    }
+
+    const nextState = state as { mode?: 'idle' | 'ready' };
+
+    this.mode = nextState.mode ?? this.mode;
+  }
+
+  public getSettingsTabs(): SettingsTab[] {
+    return [];
+  }
+
+  public paneViews(): readonly IPrimitivePaneView[] {
+    return [];
+  }
+
+  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
+    return [];
+  }
+
+  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
+    return [];
+  }
+
+  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
+    return [];
+  }
+
+  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
+    return [];
+  }
+
+  public getRenderData() {
+    return null;
+  }
+
+  public setMode(mode: 'idle' | 'ready'): void {
+    this.mode = mode;
+  }
+
+  public markReady(): void {
+    this.mode = 'ready';
+    this.resolveReady?.();
+  }
+
+  public findHandle(point: Point): DrawingHandle<'main'> | null {
+    return this.getDrawingHandleAtPoint(point);
+  }
+
+  protected getDrawingHandles(): readonly DrawingHandle<'main'>[] {
+    return [
+      {
+        id: 'main',
+        ...this.handlePoint,
+      },
+    ];
+  }
+
+  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
+    if (Math.abs(x - this.handlePoint.x) > 8 || Math.abs(y - this.handlePoint.y) > 8) {
+      return null;
+    }
+
+    return {
+      cursorStyle: 'move',
+      externalId: 'test',
+      zOrder: 'top',
+    };
+  }
+
+  protected getGeometry() {
+    return null;
+  }
+
+  protected getTimeAxisSegments(): AxisSegment[] {
+    return [];
+  }
+
+  protected getPriceAxisSegments(): AxisSegment[] {
+    return [];
+  }
+
+  protected getTimeAxisLabel(_kind: string): AxisLabel | null {
+    return null;
+  }
+
+  protected getPriceAxisLabel(_kind: string): AxisLabel | null {
+    return null;
+  }
+
+  protected handlePointerDown(): void {
+    this.pointerDownCalls += 1;
+  }
+
+  protected handleClick(): void {
+    this.clickCalls += 1;
+  }
+
+  protected handleDoubleClick(): void {
+    this.doubleClickCalls += 1;
+  }
+
+  protected handleContextMenu(): void {
+    this.contextMenuCalls += 1;
+  }
+}
+
+function createEnvironment({
+  selected = false,
+  locked = false,
+  width = 400,
+  height = 300,
+}: {
+  selected?: boolean;
+  locked?: boolean;
+  width?: number;
+  height?: number;
+} = {}) {
+  let selectedValue = selected;
+  let lockedValue = locked;
+
+  const selected$ = new BehaviorSubject(selected);
+  const locked$ = new BehaviorSubject(locked);
+
+  const interaction: DrawingInteraction = {
+    selected$,
+    locked$,
+    isSelected: jest.fn(() => selectedValue),
+    isLocked: jest.fn(() => lockedValue),
+    select: jest.fn(() => {
+      selectedValue = true;
+      selected$.next(true);
+    }),
+    deselect: jest.fn(() => {
+      selectedValue = false;
+      selected$.next(false);
+    }),
+  };
+
+  const container = document.createElement('div');
+
+  jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+    x: 0,
+    y: 0,
+    left: 0,
+    top: 0,
+    right: width,
+    bottom: height,
+    width,
+    height,
+    toJSON: jest.fn(),
+  });
+
+  const pane = {
+    paneIndex: jest.fn(() => 0),
+    getHeight: jest.fn(() => height),
+  };
+
+  const chart = {
+    panes: jest.fn(() => [pane]),
+    applyOptions: jest.fn(),
+  } as unknown as IChartApi;
+
+  const series = {
+    attachPrimitive: jest.fn(),
+    detachPrimitive: jest.fn(),
+    getPane: jest.fn(() => pane),
+  } as unknown as SeriesApi;
+
+  return {
+    container,
+    interaction,
+    chart,
+    series,
+    setSelected(value: boolean) {
+      selectedValue = value;
+      selected$.next(value);
+    },
+    setLocked(value: boolean) {
+      lockedValue = value;
+      locked$.next(value);
+    },
+  };
+}
+
+function createPointerEvent(target: Node, x: number, y: number, button = 0): PointerEvent {
+  return {
+    target,
+    clientX: x,
+    clientY: y,
+    button,
+    pointerId: 1,
+    preventDefault: jest.fn(),
+    stopPropagation: jest.fn(),
+  } as unknown as PointerEvent;
+}
+
+function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
+  return {
+    target,
+    clientX: x,
+    clientY: y,
+    preventDefault: jest.fn(),
+    stopPropagation: jest.fn(),
+  } as unknown as MouseEvent;
+}
+
+describe('SeriesDrawingBase', () => {
+  it('должен возвращать копию settings', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    const first = drawing.getSettings();
+
+    first.color = '#000000';
+
+    expect(drawing.getSettings()).toEqual({
+      color: '#ffffff',
+    });
+  });
+
+  it('должен объединять новые settings с текущими', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.updateSettings({
+      lineWidth: 2,
+    });
+
+    expect(drawing.getSettings()).toEqual({
+      color: '#ffffff',
+      lineWidth: 2,
+    });
+  });
+
+  it('должен уведомлять подписчика settings', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+    const callback = jest.fn();
+
+    const subscription = drawing.subscribeSettings(callback);
+
+    expect(callback).toHaveBeenCalledWith({
+      color: '#ffffff',
+    });
+
+    drawing.updateSettings({
+      color: '#ff0000',
+    });
+
+    expect(callback).toHaveBeenLastCalledWith({
+      color: '#ff0000',
+    });
+
+    subscription.unsubscribe();
+  });
+
+  it('должен скрывать drawing', () => {
+    const env = createEnvironment({
+      selected: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    drawing.hide();
+
+    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
+    expect(drawing.updateAllViews).toHaveBeenCalled();
+    expect(env.chart.applyOptions).toHaveBeenCalled();
+  });
+
+  it('должен снова показывать drawing', () => {
+    const env = createEnvironment({
+      selected: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    drawing.hide();
+    drawing.show();
+
+    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
+  });
+
+  it('должен показывать handle для selected drawing', () => {
+    const env = createEnvironment({
+      selected: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
+  });
+
+  it('не должен показывать handle для невыбранного ready drawing', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
+  });
+
+  it('должен показывать handle во время создания', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.setMode('idle');
+
+    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
+  });
+
+  it('не должен показывать handle для locked drawing', () => {
+    const env = createEnvironment({
+      selected: true,
+      locked: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
+  });
+
+  it('должен прикреплять DrawingHandlesPrimitive при attached', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.attached({
+      chart: env.chart,
+      series: env.series,
+      requestUpdate: jest.fn(),
+    } as unknown as SeriesAttachedParameter<Time>);
+
+    expect(env.series.attachPrimitive).toHaveBeenCalledTimes(1);
+    expect(env.series.attachPrimitive).toHaveBeenCalledWith(expect.any(DrawingHandlesPrimitive));
+
+    drawing.detached();
+  });
+
+  it('должен отсоединять DrawingHandlesPrimitive при detached', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.attached({
+      chart: env.chart,
+      series: env.series,
+      requestUpdate: jest.fn(),
+    } as unknown as SeriesAttachedParameter<Time>);
+
+    const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0];
+
+    drawing.detached();
+
+    expect(env.series.detachPrimitive).toHaveBeenCalledWith(handlesPrimitive);
+  });
+
+  it('должен возвращать false для object tree в idle', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.setMode('idle');
+
+    expect(drawing.shouldShowInObjectTree()).toBe(false);
+  });
+
+  it('должен возвращать true для object tree в ready', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.shouldShowInObjectTree()).toBe(true);
+  });
+
+  it('waitTillReady должен сразу завершаться для ready drawing', async () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    await expect(drawing.waitTillReady()).resolves.toBeUndefined();
+  });
+
+  it('waitTillReady должен ожидать перехода в ready', async () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.setMode('idle');
+
+    const promise = drawing.waitTillReady();
+
+    drawing.markReady();
+
+    await expect(promise).resolves.toBeUndefined();
+  });
+
+  it('должен возвращать исходный cursor для unlocked drawing', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('move');
+  });
+
+  it('должен заменять cursor на pointer для locked drawing', () => {
+    const env = createEnvironment({
+      locked: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('pointer');
+  });
+
+  it('должен передавать pointerDown в drawing для unlocked состояния', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.pointerDown(createPointerEvent(env.container, 50, 50));
+
+    expect(drawing.pointerDownCalls).toBe(1);
+  });
+
+  it('не должен обрабатывать событие вне container', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+    const outside = document.createElement('div');
+
+    drawing.pointerDown(createPointerEvent(outside, 50, 50));
+
+    expect(drawing.pointerDownCalls).toBe(0);
+  });
+
+  it('должен выбирать locked drawing при pointerDown по нему', () => {
+    const env = createEnvironment({
+      locked: true,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    drawing.pointerDown(createPointerEvent(env.container, 50, 50));
+
+    expect(env.interaction.select).toHaveBeenCalledTimes(1);
+    expect(drawing.pointerDownCalls).toBe(0);
+  });
+
+  it('должен вызывать click handler', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.click(createMouseEvent(env.container, 50, 50));
+
+    expect(drawing.clickCalls).toBe(1);
+  });
+
+  it('должен вызывать doubleClick handler', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.doubleClick(createMouseEvent(env.container, 50, 50));
+
+    expect(drawing.doubleClickCalls).toBe(1);
+  });
+
+  it('должен вызывать contextMenu handler', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.contextMenu(createMouseEvent(env.container, 50, 50));
+
+    expect(drawing.contextMenuCalls).toBe(1);
+  });
+
+  it('должен принимать событие на выступающей части handle поверх time axis', () => {
+    const env = createEnvironment({
+      selected: true,
+      height: 100,
+    });
+
+    const drawing = new TestDrawing(env);
+
+    drawing.handlePoint = {
+      x: 50,
+      y: 100,
+    };
+
+    drawing.attached({
+      chart: env.chart,
+      series: env.series,
+      requestUpdate: jest.fn(),
+    } as unknown as SeriesAttachedParameter<Time>);
+
+    const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0] as DrawingHandlesPrimitive;
+
+    handlesPrimitive.attached({
+      chart: env.chart,
+      series: env.series,
+      requestUpdate: jest.fn(),
+    } as unknown as SeriesAttachedParameter<Time>);
+
+    const outside = document.createElement('div');
+
+    expect(drawing.isHit(createMouseEvent(outside, 50, 104))).toBe(true);
+
+    drawing.detached();
+  });
+
+  it('должен rebind drawing на новую series', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    const newSeries = {
+      attachPrimitive: jest.fn(),
+      detachPrimitive: jest.fn(),
+    } as unknown as SeriesApi;
+
+    drawing.rebind(newSeries);
+
+    expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
+    expect(newSeries.attachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+
+  it('должен detach drawing при destroy', () => {
+    const env = createEnvironment();
+    const drawing = new TestDrawing(env);
+
+    drawing.destroy();
+
+    expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawingTestUtils.ts b/src/core/Drawings/__tests__/drawingTestUtils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..25ad42b837b1294618231747f3266623382152e5
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawingTestUtils.ts
@@ -0,0 +1,225 @@
+import { BehaviorSubject } from 'rxjs';
+
+import type { BaseDrawingParams } from '@core/Drawings/SeriesDrawingBase';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type { Coordinate, Logical, Time } from 'lightweight-charts';
+
+interface EnvironmentOptions {
+  selected?: boolean;
+  locked?: boolean;
+  seriesData?: readonly unknown[];
+  width?: number;
+  height?: number;
+}
+
+interface CanvasTargetOptions {
+  horizontalPixelRatio?: number;
+  verticalPixelRatio?: number;
+  bitmapWidth?: number;
+  bitmapHeight?: number;
+}
+
+export function createDrawingTestEnvironment({
+  selected = false,
+  locked = false,
+  seriesData = [],
+  width = 400,
+  height = 300,
+}: EnvironmentOptions = {}) {
+  let selectedValue = selected;
+  let lockedValue = locked;
+
+  const selected$ = new BehaviorSubject(selected);
+  const locked$ = new BehaviorSubject(locked);
+
+  const interaction = {
+    selected$,
+    locked$,
+    isSelected: jest.fn(() => selectedValue),
+    isLocked: jest.fn(() => lockedValue),
+    select: jest.fn(() => {
+      selectedValue = true;
+      selected$.next(true);
+    }),
+    deselect: jest.fn(() => {
+      selectedValue = false;
+      selected$.next(false);
+    }),
+  } as unknown as BaseDrawingParams['interaction'];
+
+  const container = document.createElement('div');
+
+  jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+    x: 0,
+    y: 0,
+    left: 0,
+    top: 0,
+    right: width,
+    bottom: height,
+    width,
+    height,
+    toJSON: jest.fn(),
+  });
+
+  const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
+  const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
+  const coordinateToLogical = jest.fn((coordinate: Coordinate): Logical | null => Number(coordinate) as Logical);
+  const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);
+
+  const timeScale = {
+    coordinateToTime,
+    timeToCoordinate,
+    coordinateToLogical,
+    logicalToCoordinate,
+    getVisibleLogicalRange: jest.fn(() => ({
+      from: 0 as Logical,
+      to: Math.max(seriesData.length - 1, 0) as Logical,
+    })),
+    subscribeVisibleLogicalRangeChange: jest.fn(),
+    unsubscribeVisibleLogicalRangeChange: jest.fn(),
+  };
+
+  const pane = {
+    paneIndex: jest.fn(() => 0),
+    getHeight: jest.fn(() => height),
+  };
+
+  const chart = {
+    timeScale: jest.fn(() => timeScale),
+    applyOptions: jest.fn(),
+    panes: jest.fn(() => [pane]),
+    subscribeClick: jest.fn(),
+    unsubscribeClick: jest.fn(),
+    subscribeCrosshairMove: jest.fn(),
+    unsubscribeCrosshairMove: jest.fn(),
+  } as unknown as BaseDrawingParams['chart'];
+
+  const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
+  const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
+
+  const series = {
+    coordinateToPrice,
+    priceToCoordinate,
+    data: jest.fn(() => seriesData),
+    attachPrimitive: jest.fn(),
+    detachPrimitive: jest.fn(),
+    getPane: jest.fn(() => pane),
+  } as unknown as BaseDrawingParams['series'];
+
+  return {
+    chart,
+    series,
+    container,
+    interaction,
+    timeScale,
+    pane,
+    coordinateToPrice,
+    priceToCoordinate,
+    setSelected(value: boolean) {
+      selectedValue = value;
+      selected$.next(value);
+    },
+    setLocked(value: boolean) {
+      lockedValue = value;
+      locked$.next(value);
+    },
+  };
+}
+
+export function createPointerEvent(target: Node, x: number, y: number, pointerId = 1, button = 0): PointerEvent {
+  return {
+    target,
+    clientX: x,
+    clientY: y,
+    pointerId,
+    button,
+    preventDefault: jest.fn(),
+    stopPropagation: jest.fn(),
+  } as unknown as PointerEvent;
+}
+
+export function createMouseEvent(target: Node, x: number, y: number, detail = 1): MouseEvent {
+  return {
+    target,
+    clientX: x,
+    clientY: y,
+    detail,
+    preventDefault: jest.fn(),
+    stopPropagation: jest.fn(),
+  } as unknown as MouseEvent;
+}
+
+export function createCanvasTarget({
+  horizontalPixelRatio = 2,
+  verticalPixelRatio = 2,
+  bitmapWidth = 800,
+  bitmapHeight = 600,
+}: CanvasTargetOptions = {}) {
+  const context = {
+    canvas: {
+      width: bitmapWidth,
+      height: bitmapHeight,
+    },
+    save: jest.fn(),
+    restore: jest.fn(),
+    beginPath: jest.fn(),
+    closePath: jest.fn(),
+    moveTo: jest.fn(),
+    lineTo: jest.fn(),
+    quadraticCurveTo: jest.fn(),
+    arc: jest.fn(),
+    arcTo: jest.fn(),
+    rect: jest.fn(),
+    clip: jest.fn(),
+    fill: jest.fn(),
+    stroke: jest.fn(),
+    fillRect: jest.fn(),
+    strokeRect: jest.fn(),
+    fillText: jest.fn(),
+    setLineDash: jest.fn(),
+    translate: jest.fn(),
+    rotate: jest.fn(),
+    measureText: jest.fn((text: string) => ({
+      width: text.length * 8,
+    })),
+    fillStyle: '',
+    strokeStyle: '',
+    lineWidth: 0,
+    globalAlpha: 1,
+    font: '',
+    textAlign: 'start',
+    textBaseline: 'alphabetic',
+  };
+
+  const useBitmapCoordinateSpace = jest.fn(
+    (
+      callback: (scope: {
+        context: CanvasRenderingContext2D;
+        horizontalPixelRatio: number;
+        verticalPixelRatio: number;
+        bitmapSize: {
+          width: number;
+          height: number;
+        };
+      }) => void,
+    ) => {
+      callback({
+        context: context as unknown as CanvasRenderingContext2D,
+        horizontalPixelRatio,
+        verticalPixelRatio,
+        bitmapSize: {
+          width: bitmapWidth,
+          height: bitmapHeight,
+        },
+      });
+    },
+  );
+
+  return {
+    context,
+    useBitmapCoordinateSpace,
+    target: {
+      useBitmapCoordinateSpace,
+    } as unknown as CanvasRenderingTarget2D,
+  };
+}
diff --git a/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts b/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1a259067c5fc13feac359bffa2d0647dc9e30e08
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
@@ -0,0 +1,190 @@
+import { CustomPriceAxisPaneView, CustomPriceAxisView } from '@core/Drawings/axis';
+
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+
+function createTarget(verticalPixelRatio = 2) {
+  const context = {
+    save: jest.fn(),
+    restore: jest.fn(),
+    fillRect: jest.fn(),
+    fillStyle: '',
+  } as unknown as CanvasRenderingContext2D;
+
+  const useBitmapCoordinateSpace = jest.fn(
+    (
+      callback: (scope: {
+        context: CanvasRenderingContext2D;
+        horizontalPixelRatio: number;
+        verticalPixelRatio: number;
+        bitmapSize: { width: number; height: number };
+      }) => void,
+    ) => {
+      callback({
+        context,
+        horizontalPixelRatio: 2,
+        verticalPixelRatio,
+        bitmapSize: { width: 300, height: 200 },
+      });
+    },
+  );
+
+  return {
+    context,
+    target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+    useBitmapCoordinateSpace,
+  };
+}
+
+describe('CustomPriceAxisPaneView', () => {
+  it('должен использовать bottom zOrder по умолчанию', () => {
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    expect(view.zOrder()).toBe('bottom');
+  });
+
+  it('должен использовать переданный zOrder', () => {
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [],
+      zOrder: 'top',
+    });
+
+    expect(view.zOrder()).toBe('top');
+  });
+
+  it('должен возвращать один экземпляр renderer', () => {
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    expect(view.renderer()).toBe(view.renderer());
+  });
+
+  it('не должен рисовать при отсутствии сегментов', () => {
+    const { target, useBitmapCoordinateSpace } = createTarget();
+
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    view.renderer().draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать сегмент по вертикали', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 10,
+          to: 30,
+          color: '#ff0000',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillStyle).toBe('#ff0000');
+    expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
+    expect(context.save).toHaveBeenCalledTimes(1);
+    expect(context.restore).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен корректно рисовать сегмент при обратном порядке координат', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 30,
+          to: 10,
+          color: '#00ff00',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
+  });
+
+  it('должен рисовать несколько сегментов', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomPriceAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 10,
+          to: 20,
+          color: '#ff0000',
+        },
+        {
+          from: 30,
+          to: 50,
+          color: '#00ff00',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillRect).toHaveBeenNthCalledWith(1, 0, 20, 300, 20);
+    expect(context.fillRect).toHaveBeenNthCalledWith(2, 0, 60, 300, 40);
+  });
+});
+
+describe('CustomPriceAxisView', () => {
+  it('должен возвращать данные label', () => {
+    const getAxisLabel = jest.fn(() => ({
+      coordinate: 125,
+      text: '100.50',
+      textColor: '#ffffff',
+      backgroundColor: '#000000',
+    }));
+
+    const view = new CustomPriceAxisView({
+      getAxisLabel,
+      labelKind: 'entry',
+    });
+
+    expect(view.visible()).toBe(true);
+    expect(view.tickVisible()).toBe(false);
+    expect(view.coordinate()).toBe(125);
+    expect(view.text()).toBe('100.50');
+    expect(view.textColor()).toBe('#ffffff');
+    expect(view.backColor()).toBe('#000000');
+    expect(getAxisLabel).toHaveBeenCalledWith('entry');
+  });
+
+  it('должен возвращать значения по умолчанию при отсутствии label', () => {
+    const view = new CustomPriceAxisView({
+      getAxisLabel: () => null,
+      labelKind: 'entry',
+    });
+
+    expect(view.visible()).toBe(false);
+    expect(view.tickVisible()).toBe(false);
+    expect(view.coordinate()).toBe(0);
+    expect(view.text()).toBe('');
+    expect(view.textColor()).toBe('');
+    expect(view.backColor()).toBe('');
+  });
+
+  it('не должен быть видимым при пустом тексте', () => {
+    const view = new CustomPriceAxisView({
+      getAxisLabel: () => ({
+        coordinate: 100,
+        text: '',
+        textColor: '#ffffff',
+        backgroundColor: '#000000',
+      }),
+      labelKind: 'entry',
+    });
+
+    expect(view.visible()).toBe(false);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts b/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..570590f9d8f72ac6d643d519c0ab8c89abbe8292
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
@@ -0,0 +1,190 @@
+import { CustomTimeAxisPaneView, CustomTimeAxisView } from '@core/Drawings/axis';
+
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+
+function createTarget(horizontalPixelRatio = 2) {
+  const context = {
+    save: jest.fn(),
+    restore: jest.fn(),
+    fillRect: jest.fn(),
+    fillStyle: '',
+  } as unknown as CanvasRenderingContext2D;
+
+  const useBitmapCoordinateSpace = jest.fn(
+    (
+      callback: (scope: {
+        context: CanvasRenderingContext2D;
+        horizontalPixelRatio: number;
+        verticalPixelRatio: number;
+        bitmapSize: { width: number; height: number };
+      }) => void,
+    ) => {
+      callback({
+        context,
+        horizontalPixelRatio,
+        verticalPixelRatio: 2,
+        bitmapSize: { width: 300, height: 200 },
+      });
+    },
+  );
+
+  return {
+    context,
+    target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+    useBitmapCoordinateSpace,
+  };
+}
+
+describe('CustomTimeAxisPaneView', () => {
+  it('должен использовать bottom zOrder по умолчанию', () => {
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    expect(view.zOrder()).toBe('bottom');
+  });
+
+  it('должен использовать переданный zOrder', () => {
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [],
+      zOrder: 'normal',
+    });
+
+    expect(view.zOrder()).toBe('normal');
+  });
+
+  it('должен возвращать один экземпляр renderer', () => {
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    expect(view.renderer()).toBe(view.renderer());
+  });
+
+  it('не должен рисовать при отсутствии сегментов', () => {
+    const { target, useBitmapCoordinateSpace } = createTarget();
+
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [],
+    });
+
+    view.renderer().draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать сегмент по горизонтали', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 10,
+          to: 30,
+          color: '#ff0000',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillStyle).toBe('#ff0000');
+    expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
+    expect(context.save).toHaveBeenCalledTimes(1);
+    expect(context.restore).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен корректно рисовать сегмент при обратном порядке координат', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 30,
+          to: 10,
+          color: '#00ff00',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
+  });
+
+  it('должен рисовать несколько сегментов', () => {
+    const { context, target } = createTarget();
+
+    const view = new CustomTimeAxisPaneView({
+      getAxisSegments: () => [
+        {
+          from: 10,
+          to: 20,
+          color: '#ff0000',
+        },
+        {
+          from: 30,
+          to: 50,
+          color: '#00ff00',
+        },
+      ],
+    });
+
+    view.renderer().draw(target);
+
+    expect(context.fillRect).toHaveBeenNthCalledWith(1, 20, 0, 20, 200);
+    expect(context.fillRect).toHaveBeenNthCalledWith(2, 60, 0, 40, 200);
+  });
+});
+
+describe('CustomTimeAxisView', () => {
+  it('должен возвращать данные label', () => {
+    const getAxisLabel = jest.fn(() => ({
+      coordinate: 150,
+      text: '09:30',
+      textColor: '#ffffff',
+      backgroundColor: '#000000',
+    }));
+
+    const view = new CustomTimeAxisView({
+      getAxisLabel,
+      labelKind: 'start',
+    });
+
+    expect(view.visible()).toBe(true);
+    expect(view.tickVisible()).toBe(false);
+    expect(view.coordinate()).toBe(150);
+    expect(view.text()).toBe('09:30');
+    expect(view.textColor()).toBe('#ffffff');
+    expect(view.backColor()).toBe('#000000');
+    expect(getAxisLabel).toHaveBeenCalledWith('start');
+  });
+
+  it('должен возвращать значения по умолчанию при отсутствии label', () => {
+    const view = new CustomTimeAxisView({
+      getAxisLabel: () => null,
+      labelKind: 'start',
+    });
+
+    expect(view.visible()).toBe(false);
+    expect(view.tickVisible()).toBe(false);
+    expect(view.coordinate()).toBe(0);
+    expect(view.text()).toBe('');
+    expect(view.textColor()).toBe('');
+    expect(view.backColor()).toBe('');
+  });
+
+  it('не должен быть видимым при пустом тексте', () => {
+    const view = new CustomTimeAxisView({
+      getAxisLabel: () => ({
+        coordinate: 150,
+        text: '',
+        textColor: '#ffffff',
+        backgroundColor: '#000000',
+      }),
+      labelKind: 'start',
+    });
+
+    expect(view.visible()).toBe(false);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/axisLine.test.ts b/src/core/Drawings/__tests__/drawings/axisLine.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ff40e0f8c8614a5bcf6c1f65c11e9f375aa37222
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/axisLine.test.ts
@@ -0,0 +1,580 @@
+import { AxisLine } from '@core/Drawings/axisLine/axisLine';
+import { AxisLinePaneRenderer } from '@core/Drawings/axisLine/paneRenderer';
+import { AxisLinePaneView } from '@core/Drawings/axisLine/paneView';
+import { createDefaultSettings, getAxisLineSettingsTabs } from '@core/Drawings/axisLine/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartBackground: '#ffffff',
+      chartLineColor: '#3366ff',
+      chartPriceLineText: '#ffffff',
+      axisMarkerLabelFill: '#112233',
+    },
+  }),
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date-format',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => String(value),
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: () => 'formatted-date',
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (value: string) => value,
+}));
+
+class TestAxisLine extends AxisLine {
+  public handles(): readonly DrawingHandle<'main'>[] {
+    return this.getDrawingHandles();
+  }
+
+  public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+    return this.getHoveredItem(x, y);
+  }
+
+  public timeLabel(kind: string): AxisLabel | null {
+    return this.getTimeAxisLabel(kind);
+  }
+
+  public priceLabel(kind: string): AxisLabel | null {
+    return this.getPriceAxisLabel(kind);
+  }
+
+  public pointerDown(event: PointerEvent): void {
+    this.handlePointerDown(event);
+  }
+
+  public pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+
+  public doubleClick(event: MouseEvent): void {
+    this.handleDoubleClick(event);
+  }
+}
+
+function createAxisLine(direction: 'vertical' | 'horizontal', selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const openSettings = jest.fn();
+
+  const drawing = new TestAxisLine({
+    ...env,
+    direction,
+    openSettings,
+  });
+
+  return {
+    ...env,
+    drawing,
+    openSettings,
+  };
+}
+
+describe('AxisLine', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('должен начинаться в idle', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    expect(drawing.isCreationPending()).toBe(true);
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'idle',
+      time: null,
+      price: null,
+    });
+  });
+
+  it('должен создавать vertical line', () => {
+    const { drawing, container, timeScale } = createAxisLine('vertical');
+
+    drawing.pointerDown(createPointerEvent(container, 80, 120));
+
+    expect(timeScale.coordinateToTime).toHaveBeenCalledWith(80);
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      time: 80,
+      price: null,
+    });
+  });
+
+  it('должен создавать horizontal line', () => {
+    const { drawing, container, coordinateToPrice } = createAxisLine('horizontal');
+
+    drawing.pointerDown(createPointerEvent(container, 80, 120));
+
+    expect(coordinateToPrice).toHaveBeenCalledWith(120);
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      time: null,
+      price: 120,
+    });
+  });
+
+  it('не должен начинать drawing правой кнопкой', () => {
+    const { drawing, container } = createAxisLine('vertical');
+
+    drawing.pointerDown(createPointerEvent(container, 80, 120, 1, 2));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'idle',
+      time: null,
+    });
+  });
+
+  it('должен восстанавливать state', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    drawing.setState({
+      hidden: true,
+      mode: 'ready',
+      price: 150,
+      settings: {
+        lineColor: '#ff0000',
+        text: 'Test',
+      },
+    });
+
+    expect(drawing.getState()).toMatchObject({
+      hidden: true,
+      mode: 'ready',
+      price: 150,
+      settings: {
+        lineColor: '#ff0000',
+        text: 'Test',
+      },
+    });
+  });
+
+  it('не должен возвращать render data для hidden line', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    drawing.setState({
+      hidden: true,
+      mode: 'ready',
+      price: 100,
+    });
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+
+  it('должен возвращать render data vertical line', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 100 as Time,
+    });
+
+    expect(drawing.getRenderData()).toEqual({
+      direction: 'vertical',
+      coordinate: 100,
+      lineColor: '#3366ff',
+      text: '',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#3366ff',
+    });
+  });
+
+  it('должен возвращать render data horizontal line', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    drawing.setState({
+      mode: 'ready',
+      price: 120,
+    });
+
+    expect(drawing.getRenderData()).toEqual({
+      direction: 'horizontal',
+      coordinate: 120,
+      lineColor: '#3366ff',
+      text: '',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#3366ff',
+    });
+  });
+
+  it('должен размещать vertical handle по центру высоты', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 80 as Time,
+    });
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'main',
+        x: 80,
+        y: 150,
+        shape: 'rounded',
+      },
+    ]);
+  });
+
+  it('должен размещать horizontal handle по центру ширины', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    drawing.setState({
+      mode: 'ready',
+      price: 120,
+    });
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'main',
+        x: 200,
+        y: 120,
+        shape: 'rounded',
+      },
+    ]);
+  });
+
+  it('должен возвращать time axis view только для vertical line', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    expect(drawing.timeAxisViews()).toHaveLength(1);
+    expect(drawing.priceAxisViews()).toHaveLength(0);
+  });
+
+  it('должен возвращать price axis view только для horizontal line', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    expect(drawing.timeAxisViews()).toHaveLength(0);
+    expect(drawing.priceAxisViews()).toHaveLength(1);
+  });
+
+  it('должен возвращать time label для selected vertical line', () => {
+    const { drawing } = createAxisLine('vertical', true);
+
+    drawing.setState({
+      mode: 'ready',
+      time: 80 as Time,
+    });
+
+    expect(drawing.timeLabel('main')).toEqual({
+      coordinate: 80,
+      text: 'formatted-date',
+      textColor: '#ffffff',
+      backgroundColor: '#112233',
+    });
+  });
+
+  it('должен возвращать price label для selected horizontal line', () => {
+    const { drawing } = createAxisLine('horizontal', true);
+
+    drawing.setState({
+      mode: 'ready',
+      price: 120,
+    });
+
+    expect(drawing.priceLabel('main')).toEqual({
+      coordinate: 120,
+      text: '120',
+      textColor: '#ffffff',
+      backgroundColor: '#112233',
+    });
+  });
+
+  it('не должен возвращать label для unselected line', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 80 as Time,
+    });
+
+    expect(drawing.timeLabel('main')).toBeNull();
+  });
+
+  it('должен определять hover vertical line', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    expect(drawing.hovered(54, 20)).toEqual({
+      cursorStyle: 'ew-resize',
+      externalId: 'axis-line',
+      zOrder: 'top',
+    });
+  });
+
+  it('должен определять hover horizontal line', () => {
+    const { drawing } = createAxisLine('horizontal');
+
+    drawing.setState({
+      mode: 'ready',
+      price: 50,
+    });
+
+    expect(drawing.hovered(20, 54)).toEqual({
+      cursorStyle: 'ns-resize',
+      externalId: 'axis-line',
+      zOrder: 'top',
+    });
+  });
+
+  it('не должен определять hover вдали от line', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    expect(drawing.hovered(70, 20)).toBeNull();
+  });
+
+  it('должен выбирать unselected line', () => {
+    const { drawing, interaction, container } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    drawing.pointerDown(createPointerEvent(container, 50, 20));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать vertical line', () => {
+    const { drawing, container } = createAxisLine('vertical', true);
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
+    drawing.pointerMove(createPointerEvent(container, 90, 20, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'dragging',
+      time: 90,
+    });
+
+    drawing.pointerUp(createPointerEvent(container, 90, 20, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      time: 90,
+    });
+  });
+
+  it('должен перемещать horizontal line', () => {
+    const { drawing, container } = createAxisLine('horizontal', true);
+
+    drawing.setState({
+      mode: 'ready',
+      price: 50,
+    });
+
+    drawing.pointerDown(createPointerEvent(container, 20, 50, 1));
+    drawing.pointerMove(createPointerEvent(container, 20, 90, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'dragging',
+      price: 90,
+    });
+  });
+
+  it('не должен реагировать на другой pointerId', () => {
+    const { drawing, container } = createAxisLine('vertical', true);
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
+    drawing.pointerMove(createPointerEvent(container, 90, 20, 2));
+
+    expect(drawing.getState()).toMatchObject({
+      time: 50,
+    });
+  });
+
+  it('должен открывать settings по double click', () => {
+    const { drawing, container, openSettings } = createAxisLine('vertical');
+
+    drawing.setState({
+      mode: 'ready',
+      time: 50 as Time,
+    });
+
+    drawing.doubleClick(createMouseEvent(container, 50, 20));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен обновлять settings через Base', () => {
+    const { drawing } = createAxisLine('vertical');
+
+    drawing.updateSettings({
+      lineColor: '#ff0000',
+      text: 'Updated',
+    });
+
+    expect(drawing.getSettings()).toMatchObject({
+      lineColor: '#ff0000',
+      text: 'Updated',
+      fontSize: 14,
+    });
+  });
+});
+
+describe('AxisLinePaneRenderer', () => {
+  it('не должен рисовать без render data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    const renderer = new AxisLinePaneRenderer({
+      getRenderData: () => null,
+    } as unknown as AxisLine);
+
+    renderer.draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать vertical line', () => {
+    const { context, target } = createCanvasTarget({
+      bitmapWidth: 300,
+      bitmapHeight: 200,
+    });
+
+    const renderer = new AxisLinePaneRenderer({
+      getRenderData: () => ({
+        direction: 'vertical',
+        coordinate: 20,
+        lineColor: '#ff0000',
+        text: '',
+        fontSize: 14,
+        isBold: false,
+        isItalic: false,
+        textColor: '#ffffff',
+      }),
+    } as unknown as AxisLine);
+
+    renderer.draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledWith(38, 0, 4, 200);
+  });
+
+  it('должен рисовать horizontal line', () => {
+    const { context, target } = createCanvasTarget({
+      bitmapWidth: 300,
+      bitmapHeight: 200,
+    });
+
+    const renderer = new AxisLinePaneRenderer({
+      getRenderData: () => ({
+        direction: 'horizontal',
+        coordinate: 30,
+        lineColor: '#ff0000',
+        text: '',
+        fontSize: 14,
+        isBold: false,
+        isItalic: false,
+        textColor: '#ffffff',
+      }),
+    } as unknown as AxisLine);
+
+    renderer.draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledWith(0, 58, 300, 4);
+  });
+
+  it('должен рисовать vertical text с поворотом', () => {
+    const { context, target } = createCanvasTarget({
+      bitmapWidth: 300,
+      bitmapHeight: 200,
+    });
+
+    const renderer = new AxisLinePaneRenderer({
+      getRenderData: () => ({
+        direction: 'vertical',
+        coordinate: 20,
+        lineColor: '#ff0000',
+        text: 'Test',
+        fontSize: 14,
+        isBold: true,
+        isItalic: true,
+        textColor: '#ffffff',
+      }),
+    } as unknown as AxisLine);
+
+    renderer.draw(target);
+
+    expect(context.translate).toHaveBeenCalledWith(40, 100);
+    expect(context.rotate).toHaveBeenCalledWith(-Math.PI / 2);
+    expect(context.font).toContain('italic');
+    expect(context.font).toContain('700');
+    expect(context.fillText).toHaveBeenCalledWith('Test', 0, expect.any(Number));
+  });
+});
+
+describe('AxisLinePaneView', () => {
+  it('должен возвращать renderer и top zOrder', () => {
+    const view = new AxisLinePaneView({
+      getRenderData: jest.fn(),
+    } as unknown as AxisLine);
+
+    expect(view.renderer()).toBeInstanceOf(AxisLinePaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('AxisLine settings', () => {
+  it('должен создавать defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#3366ff',
+      text: '',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#3366ff',
+    });
+  });
+
+  it('должен создавать style и text tabs', () => {
+    expect(getAxisLineSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/diapson.test.ts b/src/core/Drawings/__tests__/drawings/diapson.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a6b3eb77528fa036d08f44ecdcd788d4a3174b94
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/diapson.test.ts
@@ -0,0 +1,585 @@
+import { Diapson } from '@core/Drawings/diapson/diapson';
+import { DiapsonPaneRenderer } from '@core/Drawings/diapson/paneRenderer';
+import { DiapsonPaneView } from '@core/Drawings/diapson/paneView';
+import { createDefaultSettings, getDiapsonSettingsTabs } from '@core/Drawings/diapson/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DiapsonRangeMode, DiapsonRenderData } from '@core/Drawings/diapson/diapson';
+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: {
+      chartBackground: '#101010',
+      chartLineColor: '#2962ff',
+      diapsonStrokeFill: '#ff9800',
+      diapsonAreaFill: '#ff980033',
+      axisRangeTooltipText: '#ffffff',
+      axisRangeTooltipFill: '#333333',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (key: string) => key,
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPercent: (value: number) => `${value}%`,
+  formatPrice: (value: number) => `price:${value}`,
+  formatSignedNumber: (value: number) => (value > 0 ? `+${value}` : String(value)),
+  formatVolume: (value: number) => `volume:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestDiapson extends Diapson {
+  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 geometry() {
+    return this.getGeometry();
+  }
+
+  public pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+}
+
+function createDiapson(
+  rangeMode: DiapsonRangeMode = 'date',
+  options: {
+    selected?: boolean;
+    data?: readonly Record<string, unknown>[];
+    stepSize?: number;
+    stepLabel?: string;
+  } = {},
+) {
+  const env = createDrawingTestEnvironment({
+    selected: options.selected,
+    seriesData: options.data,
+  });
+
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const drawing = new TestDiapson({
+    ...env,
+    rangeMode,
+    removeSelf,
+    openSettings,
+    stepSize: options.stepSize,
+    stepLabel: options.stepLabel,
+  });
+
+  return {
+    ...env,
+    drawing,
+    removeSelf,
+    openSettings,
+  };
+}
+
+function setReady(
+  drawing: TestDiapson,
+  {
+    rangeMode = 'date',
+    startTime = 50,
+    endTime = 150,
+    startPrice = 50,
+    endPrice = 150,
+  }: {
+    rangeMode?: DiapsonRangeMode;
+    startTime?: number;
+    endTime?: number;
+    startPrice?: number;
+    endPrice?: number;
+  } = {},
+) {
+  drawing.setState({
+    mode: 'ready',
+    rangeMode,
+    startTime: startTime as Time,
+    endTime: endTime as Time,
+    startPrice,
+    endPrice,
+  });
+}
+
+function createRendererDiapson(data: DiapsonRenderData | null): Diapson {
+  return {
+    getRenderData: () => data,
+  } as unknown as Diapson;
+}
+
+function createRenderData(rangeMode: DiapsonRangeMode, overrides: Partial<DiapsonRenderData> = {}): DiapsonRenderData {
+  return {
+    left: 10,
+    right: 90,
+    top: 20,
+    bottom: 80,
+    width: 80,
+    height: 60,
+    startPoint: { x: 10, y: 20 },
+    endPoint: { x: 90, y: 80 },
+    rangeMode,
+    showFill: true,
+    labelLines: [],
+    borderColor: '#ff0000',
+    fillColor: '#ff000033',
+    labelTextColor: '#ffffff',
+    labelBackgroundColor: '#000000',
+    fontSize: 10,
+    isBold: false,
+    isItalic: false,
+    ...overrides,
+  };
+}
+
+describe('Diapson', () => {
+  it('должен прикреплять primitive', () => {
+    const { drawing, series } = createDiapson();
+
+    expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+
+  it('должен начинаться в idle', () => {
+    const { drawing } = createDiapson();
+
+    expect(drawing.isCreationPending()).toBe(true);
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'idle',
+      rangeMode: 'date',
+      startTime: null,
+      endTime: null,
+      startPrice: null,
+      endPrice: null,
+    });
+  });
+
+  it('должен создавать диапазон двумя pointerDown', () => {
+    const { drawing, container } = createDiapson();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 60));
+    drawing.pointerDown(createPointerEvent(container, 150, 160));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 50,
+      endTime: 150,
+      startPrice: 60,
+      endPrice: 160,
+    });
+  });
+
+  it('должен удалять слишком маленький диапазон', () => {
+    const { drawing, container, removeSelf } = createDiapson();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 53, 53));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен менять rangeMode со сбросом drawing', () => {
+    const { drawing, interaction } = createDiapson();
+
+    setReady(drawing);
+
+    drawing.setRangeMode('price');
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'idle',
+      rangeMode: 'price',
+      startTime: null,
+      endTime: null,
+      startPrice: null,
+      endPrice: null,
+    });
+
+    expect(interaction.deselect).toHaveBeenCalled();
+  });
+
+  it('не должен сбрасываться при текущем rangeMode', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+    drawing.setRangeMode('date');
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 50,
+      endTime: 150,
+    });
+  });
+
+  it('должен возвращать нормализованную geometry', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing, {
+      startTime: 150,
+      endTime: 50,
+      startPrice: 160,
+      endPrice: 60,
+    });
+
+    expect(drawing.geometry()).toEqual({
+      left: 50,
+      right: 150,
+      top: 60,
+      bottom: 160,
+      width: 100,
+      height: 100,
+      startPoint: { x: 150, y: 160 },
+      endPoint: { x: 50, y: 60 },
+    });
+  });
+
+  it('должен возвращать end и start handles', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'end',
+        x: 150,
+        y: 150,
+        shape: 'circle',
+        size: 12,
+        borderWidth: 2,
+      },
+      {
+        id: 'start',
+        x: 50,
+        y: 50,
+        shape: 'circle',
+        size: 12,
+        borderWidth: 2,
+      },
+    ]);
+  });
+
+  it('должен возвращать render data', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+
+    expect(drawing.getRenderData()).toMatchObject({
+      left: 50,
+      right: 150,
+      top: 50,
+      bottom: 150,
+      rangeMode: 'date',
+      borderColor: '#ff9800',
+      fillColor: '#ff980033',
+    });
+  });
+
+  it('должен возвращать axis segments selected drawing', () => {
+    const { drawing } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+
+    expect(drawing.priceSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+  });
+
+  it('не должен возвращать axis segments unselected drawing', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([]);
+    expect(drawing.priceSegments()).toEqual([]);
+  });
+
+  it('должен возвращать axis labels', () => {
+    const { drawing } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    expect(drawing.timeLabel('left')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(drawing.timeLabel('right')).toMatchObject({
+      coordinate: 150,
+      text: 'date:150',
+    });
+
+    expect(drawing.priceLabel('top')).toMatchObject({
+      coordinate: 50,
+      text: 'price:50',
+    });
+
+    expect(drawing.priceLabel('bottom')).toMatchObject({
+      coordinate: 150,
+      text: 'price:150',
+    });
+  });
+
+  it('должен возвращать move для body', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+
+    expect(drawing.hovered(100, 100)?.cursorStyle).toBe('move');
+  });
+
+  it('должен возвращать resize для selected handle', () => {
+    const { drawing } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('nwse-resize');
+  });
+
+  it('должен выбирать диапазон первым нажатием', () => {
+    const { drawing, container, interaction } = createDiapson();
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать весь диапазон', () => {
+    const { drawing, container } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
+    drawing.pointerMove(createPointerEvent(container, 120, 130, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      startTime: 70,
+      endTime: 170,
+      startPrice: 80,
+      endPrice: 180,
+    });
+
+    drawing.pointerUp(createPointerEvent(container, 120, 130, 1));
+
+    expect(drawing.getState().mode).toBe('ready');
+  });
+
+  it('должен resize start', () => {
+    const { drawing, container } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
+    drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
+    drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      startTime: 70,
+      startPrice: 80,
+      endTime: 150,
+      endPrice: 150,
+    });
+  });
+
+  it('должен resize end', () => {
+    const { drawing, container } = createDiapson('date', { selected: true });
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
+    drawing.pointerMove(createPointerEvent(container, 180, 190, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      startTime: 50,
+      startPrice: 50,
+      endTime: 180,
+      endPrice: 190,
+    });
+  });
+
+  it('должен открывать settings', () => {
+    const { drawing, container, openSettings } = createDiapson();
+
+    setReady(drawing);
+
+    drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен формировать price metrics', () => {
+    const { drawing } = createDiapson('price', {
+      stepSize: 5,
+      stepLabel: 'pts',
+    });
+
+    setReady(drawing, {
+      rangeMode: 'price',
+      startPrice: 100,
+      endPrice: 120,
+    });
+
+    expect(drawing.getRenderData()?.labelLines).toEqual(['+20 (20%) 4 pts']);
+  });
+
+  it('должен формировать date metrics', () => {
+    const { drawing } = createDiapson('date', {
+      data: [
+        { time: 1000, volume: 10 },
+        { time: 1060, volume: 20 },
+        { time: 1120, volume: 30 },
+      ],
+    });
+
+    setReady(drawing, {
+      startTime: 1000,
+      endTime: 1120,
+    });
+
+    expect(drawing.getRenderData()?.labelLines).toEqual(['2 bars, 2m', 'Vol volume:60']);
+  });
+
+  it('не должен возвращать render data hidden drawing', () => {
+    const { drawing } = createDiapson();
+
+    setReady(drawing);
+    drawing.hide();
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+});
+
+describe('DiapsonPaneRenderer', () => {
+  it('не должен рисовать без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new DiapsonPaneRenderer(createRendererDiapson(null)).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать date range', () => {
+    const { target, context } = createCanvasTarget();
+    const data = createRenderData('date');
+
+    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledWith(20, 40, 160, 120);
+    expect(context.stroke).toHaveBeenCalled();
+  });
+
+  it('должен рисовать price range', () => {
+    const { target, context } = createCanvasTarget();
+    const data = createRenderData('price');
+
+    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
+    expect(context.lineTo).toHaveBeenCalledWith(180, 40);
+  });
+
+  it('должен рисовать labels', () => {
+    const { target, context } = createCanvasTarget();
+
+    const data = createRenderData('date', {
+      labelLines: ['First', 'Second'],
+      isBold: true,
+      isItalic: true,
+    });
+
+    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+    expect(context.fillText).toHaveBeenCalledTimes(2);
+    expect(context.font).toContain('italic');
+    expect(context.font).toContain('700');
+  });
+});
+
+describe('DiapsonPaneView', () => {
+  it('должен возвращать renderer и top', () => {
+    const view = new DiapsonPaneView(createRendererDiapson(null));
+
+    expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('Diapson settings', () => {
+  it('должен создавать defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      borderColor: '#ff9800',
+      fillColor: '#ff980033',
+      labelTextColor: '#ffffff',
+      labelBackgroundColor: '#333333',
+      fontSize: 10,
+      isBold: false,
+      isItalic: false,
+    });
+  });
+
+  it('должен создавать style и text tabs', () => {
+    expect(getDiapsonSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..18ffb86f18c53f8d32edd8f004c5d10cc7526fef
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
@@ -0,0 +1,466 @@
+import { FibonacciRetracement } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
+import { FibonacciRetracementPaneRenderer } from '@core/Drawings/fibonacciRetracement/paneRenderer';
+import { FibonacciRetracementPaneView } from '@core/Drawings/fibonacciRetracement/paneView';
+import {
+  cloneFibonacciRetracementSettings,
+  createDefaultSettings,
+  formatLevelLabel,
+  getFibonacciRetracementSettingsTabs,
+  getFibonacciRetracementSettingsValues,
+  getVisibleFibonacciLevels,
+  mergeFibonacciRetracementSettings,
+} from '@core/Drawings/fibonacciRetracement/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { FibonacciRetracementRenderData } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
+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: {
+      chartBackground: '#101010',
+      chartLineColor: '#2962ff',
+      chartCrosshairLine: '#999999',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (key: string) => key,
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestFibonacciRetracement extends FibonacciRetracement {
+  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 pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+}
+
+function createDrawing(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const drawing = new TestFibonacciRetracement({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return {
+    ...env,
+    drawing,
+    removeSelf,
+    openSettings,
+  };
+}
+
+function setReady(drawing: TestFibonacciRetracement): void {
+  drawing.setState({
+    mode: 'ready',
+    startTime: 50 as Time,
+    endTime: 150 as Time,
+    startPrice: 50,
+    endPrice: 150,
+  });
+}
+
+function createRendererDrawing(data: FibonacciRetracementRenderData | null): FibonacciRetracement {
+  return {
+    getRenderData: () => data,
+  } as unknown as FibonacciRetracement;
+}
+
+describe('FibonacciRetracement', () => {
+  it('должен прикреплять primitive', () => {
+    const { drawing, series } = createDrawing();
+
+    expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+
+  it('должен начинаться в idle', () => {
+    const { drawing } = createDrawing();
+
+    expect(drawing.isCreationPending()).toBe(true);
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'idle',
+      startTime: null,
+      endTime: null,
+      startPrice: null,
+      endPrice: null,
+    });
+  });
+
+  it('должен создаваться двумя pointerDown', () => {
+    const { drawing, container } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 150, 150));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 50,
+      endTime: 150,
+      startPrice: 50,
+      endPrice: 150,
+    });
+  });
+
+  it('должен удалять слишком маленький retracement', () => {
+    const { drawing, container, removeSelf } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 53, 53));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен возвращать end и start handles', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'end',
+        x: 150,
+        y: 150,
+      },
+      {
+        id: 'start',
+        x: 50,
+        y: 50,
+      },
+    ]);
+  });
+
+  it('должен рассчитывать levels', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    const data = drawing.getRenderData();
+
+    expect(data?.levels).toHaveLength(11);
+    expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(150);
+    expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(50);
+    expect(data?.backgroundOpacity).toBe(0.18);
+  });
+
+  it('должен учитывать reverse', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+    drawing.updateSettings({ reverse: true });
+
+    const data = drawing.getRenderData();
+
+    expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(50);
+    expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(150);
+  });
+
+  it('должен возвращать selected axis segments', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+
+    expect(drawing.priceSegments()).toHaveLength(1);
+  });
+
+  it('не должен возвращать axis segments unselected drawing', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([]);
+    expect(drawing.priceSegments()).toEqual([]);
+  });
+
+  it('должен возвращать axis labels', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeLabel('start')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(drawing.timeLabel('end')).toMatchObject({
+      coordinate: 150,
+      text: 'date:150',
+    });
+  });
+
+  it('должен возвращать pointer для selected handle', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.hovered(50, 50)).toEqual({
+      cursorStyle: 'pointer',
+      externalId: 'fibonacci-retracement-position',
+      zOrder: 'top',
+    });
+  });
+
+  it('должен выбирать unselected retracement', () => {
+    const { drawing, interaction, container } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать start handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
+    drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
+    drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      startTime: 70,
+      startPrice: 80,
+      endTime: 150,
+      endPrice: 150,
+    });
+  });
+
+  it('должен перемещать end handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
+    drawing.pointerMove(createPointerEvent(container, 180, 170, 1));
+
+    expect(drawing.getState()).toMatchObject({
+      endTime: 180,
+      endPrice: 170,
+    });
+  });
+
+  it('должен открывать settings', () => {
+    const { drawing, container, openSettings } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не должен возвращать render data hidden drawing', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+    drawing.hide();
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+});
+
+describe('FibonacciRetracementPaneRenderer', () => {
+  it('не должен рисовать без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new FibonacciRetracementPaneRenderer(createRendererDrawing(null)).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать levels и background', () => {
+    const data: FibonacciRetracementRenderData = {
+      startPoint: { x: 10, y: 20 },
+      endPoint: { x: 90, y: 80 },
+      left: 10,
+      right: 90,
+      top: 20,
+      bottom: 80,
+      width: 80,
+      height: 60,
+      levels: [
+        {
+          id: '0',
+          value: 0,
+          price: 100,
+          y: 20,
+          color: '#ff0000',
+          text: '0 (100)',
+        },
+        {
+          id: '1',
+          value: 1,
+          price: 50,
+          y: 80,
+          color: '#00ff00',
+          text: '1 (50)',
+        },
+      ],
+      areas: [
+        {
+          top: 20,
+          bottom: 80,
+          color: '#00ff00',
+        },
+      ],
+      showBackground: true,
+      backgroundOpacity: 0.18,
+      reverse: false,
+      labelsPosition: 'right',
+      showPrices: true,
+      showLevelValues: true,
+      fontSize: 12,
+      isBold: false,
+      isItalic: false,
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new FibonacciRetracementPaneRenderer(createRendererDrawing(data)).draw(target);
+
+    expect(context.fillRect).toHaveBeenCalled();
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalledTimes(2);
+  });
+});
+
+describe('FibonacciRetracementPaneView', () => {
+  it('должен возвращать renderer и top', () => {
+    const view = new FibonacciRetracementPaneView(createRendererDrawing(null));
+
+    expect(view.renderer()).toBeInstanceOf(FibonacciRetracementPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('FibonacciRetracement settings', () => {
+  it('должен создавать независимые defaults', () => {
+    const first = createDefaultSettings();
+    const second = createDefaultSettings();
+
+    first.levels[0].visible = false;
+
+    expect(second.levels[0].visible).toBe(true);
+  });
+
+  it('должен deep clone settings', () => {
+    const settings = createDefaultSettings();
+    const clone = cloneFibonacciRetracementSettings(settings);
+
+    clone.levels[0].color = '#000000';
+
+    expect(settings.levels[0].color).not.toBe('#000000');
+  });
+
+  it('должен merge level settings', () => {
+    const result = mergeFibonacciRetracementSettings(createDefaultSettings(), {
+      reverse: true,
+      level_0_236_visible: false,
+      level_0_236_color: '#123456',
+    });
+
+    const level = result.levels.find(({ id }) => id === '0.236');
+
+    expect(result.reverse).toBe(true);
+    expect(level?.visible).toBe(false);
+    expect(level?.color).toBe('#123456');
+  });
+
+  it('должен возвращать только visible levels', () => {
+    const settings = createDefaultSettings();
+
+    settings.levels[0].visible = false;
+
+    expect(getVisibleFibonacciLevels(settings)).toHaveLength(settings.levels.length - 1);
+  });
+
+  it('должен форматировать level label', () => {
+    expect(formatLevelLabel(0.618)).toBe('0,618');
+    expect(formatLevelLabel(1)).toBe('1');
+  });
+
+  it('должен создавать style, text и levels tabs', () => {
+    expect(getFibonacciRetracementSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual([
+      'style',
+      'text',
+      'levels',
+    ]);
+  });
+
+  it('должен преобразовывать settings values', () => {
+    expect(getFibonacciRetracementSettingsValues(createDefaultSettings())).toMatchObject({
+      showBackground: true,
+      backgroundOpacity: 18,
+      reverse: false,
+    });
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/line.test.ts b/src/core/Drawings/__tests__/drawings/line.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e6250a8dd357fa84022960609e1224b618a0464
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/line.test.ts
@@ -0,0 +1,398 @@
+import { LineDrawing } from '@core/Drawings/line/lineDrawing';
+import { LineDrawingPaneRenderer } from '@core/Drawings/line/paneRenderer';
+import { LineDrawingPaneView } from '@core/Drawings/line/paneView';
+import { createDefaultSettings, getLineDrawingSettingsTabs } from '@core/Drawings/line/settings';
+
+import { LineMarker } from '@src/types';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartLineColor: '#2962ff',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (key: string) => key,
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestLineDrawing extends LineDrawing {
+  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 pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+}
+
+function createDrawing(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const drawing = new TestLineDrawing({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return {
+    ...env,
+    drawing,
+    removeSelf,
+    openSettings,
+  };
+}
+
+function setReady(drawing: TestLineDrawing): void {
+  drawing.setState({
+    mode: 'ready',
+    startAnchor: {
+      time: 50 as Time,
+      price: 50,
+    },
+    endAnchor: {
+      time: 150 as Time,
+      price: 150,
+    },
+  });
+}
+
+function createRendererDrawing(data: LineDrawingRenderData | null): LineDrawing {
+  return {
+    getRenderData: () => data,
+  } as unknown as LineDrawing;
+}
+
+describe('LineDrawing', () => {
+  it('должен прикреплять primitive', () => {
+    const { drawing, series } = createDrawing();
+
+    expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+
+  it('должен создаваться двумя pointerDown', () => {
+    const { drawing, container } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 150, 150));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      startAnchor: { time: 50, price: 50 },
+      endAnchor: { time: 150, price: 150 },
+    });
+  });
+
+  it('должен удалять слишком короткую line', () => {
+    const { drawing, container, removeSelf } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 52, 52));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен возвращать два handles', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'start',
+        x: 50,
+        y: 50,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'end',
+        x: 150,
+        y: 150,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ]);
+  });
+
+  it('должен возвращать render data', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.getRenderData()).toMatchObject({
+      startPoint: { x: 50, y: 50 },
+      endPoint: { x: 150, y: 150 },
+      lineColor: '#2962ff',
+      fontSize: 14,
+    });
+  });
+
+  it('должен возвращать selected axis segments', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+
+    expect(drawing.priceSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+  });
+
+  it('должен возвращать axis labels', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeLabel('start')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(drawing.priceLabel('end')).toMatchObject({
+      coordinate: 150,
+      text: 'price:150',
+    });
+  });
+
+  it('должен определять handles и body', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+    expect(drawing.hovered(100, 100)?.cursorStyle).toBe('grab');
+    expect(drawing.hovered(300, 100)).toBeNull();
+  });
+
+  it('должен выбирать unselected line', () => {
+    const { drawing, container, interaction } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать start handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerMove(createPointerEvent(container, 70, 80));
+    drawing.pointerUp(createPointerEvent(container, 70, 80));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 70,
+        price: 80,
+      },
+      endAnchor: {
+        time: 150,
+        price: 150,
+      },
+    });
+  });
+
+  it('должен перемещать end handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 150, 150));
+    drawing.pointerMove(createPointerEvent(container, 170, 180));
+
+    expect(drawing.getState()).toMatchObject({
+      endAnchor: {
+        time: 170,
+        price: 180,
+      },
+    });
+  });
+
+  it('должен перемещать всю line', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 100));
+    drawing.pointerMove(createPointerEvent(container, 120, 130));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 70,
+        price: 80,
+      },
+      endAnchor: {
+        time: 170,
+        price: 180,
+      },
+    });
+  });
+
+  it('должен открывать settings', () => {
+    const { drawing, container, openSettings } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не должен возвращать render data hidden drawing', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+    drawing.hide();
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+});
+
+describe('LineDrawingPaneRenderer', () => {
+  it('не должен рисовать без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new LineDrawingPaneRenderer(createRendererDrawing(null)).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать line и text', () => {
+    const data: LineDrawingRenderData = {
+      startPoint: { x: 10, y: 20 },
+      endPoint: { x: 90, y: 80 },
+      left: 10,
+      right: 90,
+      top: 20,
+      bottom: 80,
+      lineColor: '#ff0000',
+      startMarker: LineMarker.arrow,
+      endMarker: LineMarker.arrow,
+      fontSize: 14,
+      text: 'Test',
+      isBold: true,
+      isItalic: true,
+      textColor: '#ffffff',
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new LineDrawingPaneRenderer(createRendererDrawing(data)).draw(target);
+
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalled();
+    expect(context.translate).toHaveBeenCalled();
+  });
+});
+
+describe('LineDrawingPaneView', () => {
+  it('должен возвращать renderer и top', () => {
+    const view = new LineDrawingPaneView(createRendererDrawing(null));
+
+    expect(view.renderer()).toBeInstanceOf(LineDrawingPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('LineDrawing settings', () => {
+  it('должен создавать defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      startMarker: LineMarker.normal,
+      endMarker: LineMarker.normal,
+      lineColor: '#2962ff',
+      fontSize: 14,
+      text: '',
+      isBold: false,
+      isItalic: false,
+      textColor: '#2962ff',
+    });
+  });
+
+  it('должен применять default marker override', () => {
+    expect(
+      createDefaultSettings({
+        startMarker: LineMarker.arrow,
+      }),
+    ).toMatchObject({
+      startMarker: LineMarker.arrow,
+      endMarker: LineMarker.normal,
+    });
+  });
+
+  it('должен создавать style и text tabs', () => {
+    expect(getLineDrawingSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22b61d43f1339553773a06bcb0e9b7e79648b4bd
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
@@ -0,0 +1,424 @@
+import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
+import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
+import { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
+import { createDefaultSettings, getParallelChannelSettingsTabs } from '@core/Drawings/parallelChannel/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartLineColor: '#2962ff',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (key: string) => key,
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestParallelChannel extends ParallelChannel {
+  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 pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+}
+
+function createDrawing(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const openSettings = jest.fn();
+
+  const drawing = new TestParallelChannel({
+    ...env,
+    openSettings,
+  });
+
+  return {
+    ...env,
+    drawing,
+    openSettings,
+  };
+}
+
+function setReady(drawing: TestParallelChannel): void {
+  drawing.setState({
+    mode: 'ready',
+    startAnchor: {
+      time: 50 as Time,
+      price: 50,
+    },
+    endAnchor: {
+      time: 150 as Time,
+      price: 80,
+    },
+    priceOffset: 60,
+  });
+}
+
+function createRendererDrawing(data: ParallelChannelRenderData | null): ParallelChannel {
+  return {
+    getRenderData: () => data,
+  } as unknown as ParallelChannel;
+}
+
+describe('ParallelChannel', () => {
+  it('должен создаваться за три этапа', () => {
+    const { drawing, container } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+
+    expect(drawing.getState().mode).toBe('drawing-line');
+
+    drawing.pointerDown(createPointerEvent(container, 150, 80));
+
+    expect(drawing.getState().mode).toBe('drawing-channel');
+
+    drawing.pointerDown(createPointerEvent(container, 100, 130));
+
+    expect(drawing.getState().mode).toBe('ready');
+  });
+
+  it('должен восстанавливать dragging как ready', () => {
+    const { drawing } = createDrawing();
+
+    drawing.setState({
+      mode: 'dragging',
+    });
+
+    expect(drawing.getState().mode).toBe('ready');
+  });
+
+  it('должен возвращать шесть handles', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'parallel-end',
+        x: 150,
+        y: 140,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'parallel-middle',
+        x: 100,
+        y: 125,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'parallel-start',
+        x: 50,
+        y: 110,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-end',
+        x: 150,
+        y: 80,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-middle',
+        x: 100,
+        y: 65,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-start',
+        x: 50,
+        y: 50,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ]);
+  });
+
+  it('должен возвращать selected axis segments', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+
+    expect(drawing.priceSegments()).toEqual([
+      {
+        from: 50,
+        to: 140,
+        color: '#2962ff44',
+      },
+    ]);
+  });
+
+  it('должен возвращать time labels', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeLabel('start')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(drawing.timeLabel('end')).toMatchObject({
+      coordinate: 150,
+      text: 'date:150',
+    });
+  });
+
+  it('должен возвращать price labels', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.priceLabel('main-start')?.text).toBe('price:50');
+    expect(drawing.priceLabel('main-end')?.text).toBe('price:80');
+    expect(drawing.priceLabel('parallel-start')?.text).toBe('price:110');
+    expect(drawing.priceLabel('parallel-end')?.text).toBe('price:140');
+  });
+
+  it('должен возвращать pointer unselected channel', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.hovered(100, 90)?.cursorStyle).toBe('pointer');
+  });
+
+  it('должен возвращать move selected handle', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+  });
+
+  it('должен возвращать grab внутри selected channel', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.hovered(100, 90)?.cursorStyle).toBe('grab');
+  });
+
+  it('должен выбирать unselected channel', () => {
+    const { drawing, container, interaction } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 90));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать main-start', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerMove(createPointerEvent(container, 60, 60));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 60,
+        price: 60,
+      },
+      endAnchor: {
+        time: 150,
+        price: 80,
+      },
+      priceOffset: 60,
+    });
+  });
+
+  it('должен перемещать parallel-middle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 125));
+    drawing.pointerMove(createPointerEvent(container, 100, 145));
+
+    expect(drawing.getState().priceOffset).toBe(80);
+  });
+
+  it('должен перемещать channel целиком', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 100, 90));
+    drawing.pointerMove(createPointerEvent(container, 120, 100));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 70,
+        price: 60,
+      },
+      endAnchor: {
+        time: 170,
+        price: 90,
+      },
+      priceOffset: 60,
+    });
+  });
+
+  it('должен открывать settings', () => {
+    const { drawing, container, openSettings } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.doubleClick(createMouseEvent(container, 100, 90));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не должен возвращать data hidden channel', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+    drawing.hide();
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+});
+
+describe('ParallelChannelPaneRenderer', () => {
+  it('не должен рисовать без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new ParallelChannelPaneRenderer(createRendererDrawing(null)).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать channel и text', () => {
+    const data: ParallelChannelRenderData = {
+      startPoint: { x: 10, y: 20 },
+      mainMiddlePoint: { x: 50, y: 30 },
+      endPoint: { x: 90, y: 40 },
+      parallelStartPoint: { x: 10, y: 80 },
+      parallelMiddlePoint: { x: 50, y: 90 },
+      parallelEndPoint: { x: 90, y: 100 },
+      middleStartPoint: { x: 10, y: 50 },
+      middleEndPoint: { x: 90, y: 70 },
+      left: 10,
+      right: 90,
+      top: 20,
+      bottom: 100,
+      lineColor: '#ff0000',
+      backgroundColor: '#00ff0033',
+      showMiddleLine: true,
+      fontSize: 14,
+      text: 'Channel',
+      isBold: true,
+      isItalic: false,
+      textColor: '#ffffff',
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new ParallelChannelPaneRenderer(createRendererDrawing(data)).draw(target);
+
+    expect(context.fill).toHaveBeenCalled();
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.setLineDash).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalled();
+  });
+});
+
+describe('ParallelChannelPaneView', () => {
+  it('должен возвращать renderer и top', () => {
+    const view = new ParallelChannelPaneView(createRendererDrawing(null));
+
+    expect(view.renderer()).toBeInstanceOf(ParallelChannelPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('ParallelChannel settings', () => {
+  it('должен создавать defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#2962ff',
+      backgroundColor: '#2962ff44',
+      showMiddleLine: true,
+      fontSize: 14,
+      text: '',
+      isBold: false,
+      isItalic: false,
+      textColor: '#2962ff',
+    });
+  });
+
+  it('должен создавать style и text tabs', () => {
+    expect(getParallelChannelSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/ray.test.ts b/src/core/Drawings/__tests__/drawings/ray.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4c482935eda240497c9119e916d9a97605c6d0a2
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/ray.test.ts
@@ -0,0 +1,400 @@
+import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
+import { RayPaneView } from '@core/Drawings/ray/paneView';
+import { Ray } from '@core/Drawings/ray/ray';
+import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { RayRenderData } from '@core/Drawings/ray/ray';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartLineColor: '#2962ff',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({
+  t: (key: string) => key,
+}));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestRay extends Ray {
+  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 pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+}
+
+function createDrawing(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const drawing = new TestRay({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return {
+    ...env,
+    drawing,
+    removeSelf,
+    openSettings,
+  };
+}
+
+function setReady(drawing: TestRay): void {
+  drawing.setState({
+    mode: 'ready',
+    startAnchor: {
+      time: 50 as Time,
+      price: 50,
+    },
+    directionAnchor: {
+      time: 150 as Time,
+      price: 100,
+    },
+  });
+}
+
+function createRendererDrawing(data: RayRenderData | null): Ray {
+  return {
+    getRenderData: () => data,
+  } as unknown as Ray;
+}
+
+describe('Ray', () => {
+  it('должен прикреплять primitive', () => {
+    const { drawing, series } = createDrawing();
+
+    expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+  });
+
+  it('должен создаваться двумя pointerDown', () => {
+    const { drawing, container } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 150, 100));
+
+    expect(drawing.getState()).toMatchObject({
+      mode: 'ready',
+      startAnchor: {
+        time: 50,
+        price: 50,
+      },
+      directionAnchor: {
+        time: 150,
+        price: 100,
+      },
+    });
+  });
+
+  it('должен удалять слишком короткий ray', () => {
+    const { drawing, container, removeSelf } = createDrawing();
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerDown(createPointerEvent(container, 52, 52));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен продолжаться до границы container', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.getRenderData()).toMatchObject({
+      startPoint: {
+        x: 50,
+        y: 50,
+      },
+      directionPoint: {
+        x: 150,
+        y: 100,
+      },
+      rayEndPoint: {
+        x: 400,
+        y: 225,
+      },
+    });
+  });
+
+  it('должен возвращать start и direction handles', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+
+    expect(drawing.handles()).toEqual([
+      {
+        id: 'start',
+        x: 50,
+        y: 50,
+        shape: 'circle',
+        borderWidth: 2,
+        strokeColor: '#2962ff',
+      },
+      {
+        id: 'direction',
+        x: 150,
+        y: 100,
+        shape: 'circle',
+        borderWidth: 2,
+        strokeColor: '#2962ff',
+      },
+    ]);
+  });
+
+  it('должен возвращать selected axis segments', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeSegments()).toEqual([
+      {
+        from: 50,
+        to: 150,
+        color: '#2962ff44',
+      },
+    ]);
+
+    expect(drawing.priceSegments()).toEqual([
+      {
+        from: 50,
+        to: 100,
+        color: '#2962ff44',
+      },
+    ]);
+  });
+
+  it('должен возвращать axis labels', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.timeLabel('direction')).toMatchObject({
+      coordinate: 150,
+      text: 'date:150',
+    });
+
+    expect(drawing.priceLabel('start')).toMatchObject({
+      coordinate: 50,
+      text: 'price:50',
+    });
+  });
+
+  it('должен определять handles и body', () => {
+    const { drawing } = createDrawing(true);
+
+    setReady(drawing);
+
+    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+    expect(drawing.hovered(200, 125)?.cursorStyle).toBe('grab');
+    expect(drawing.hovered(200, 200)).toBeNull();
+  });
+
+  it('должен выбирать unselected ray', () => {
+    const { drawing, interaction, container } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 200, 125));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен перемещать start handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 50, 50));
+    drawing.pointerMove(createPointerEvent(container, 70, 80));
+    drawing.pointerUp(createPointerEvent(container, 70, 80));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 70,
+        price: 80,
+      },
+      directionAnchor: {
+        time: 150,
+        price: 100,
+      },
+    });
+  });
+
+  it('должен перемещать direction handle', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 150, 100));
+    drawing.pointerMove(createPointerEvent(container, 180, 130));
+
+    expect(drawing.getState()).toMatchObject({
+      directionAnchor: {
+        time: 180,
+        price: 130,
+      },
+    });
+  });
+
+  it('должен перемещать ray целиком', () => {
+    const { drawing, container } = createDrawing(true);
+
+    setReady(drawing);
+
+    drawing.pointerDown(createPointerEvent(container, 200, 125));
+    drawing.pointerMove(createPointerEvent(container, 220, 155));
+
+    expect(drawing.getState()).toMatchObject({
+      startAnchor: {
+        time: 70,
+        price: 80,
+      },
+      directionAnchor: {
+        time: 170,
+        price: 130,
+      },
+    });
+  });
+
+  it('должен открывать settings', () => {
+    const { drawing, container, openSettings } = createDrawing();
+
+    setReady(drawing);
+
+    drawing.doubleClick(createMouseEvent(container, 200, 125));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не должен возвращать data hidden ray', () => {
+    const { drawing } = createDrawing();
+
+    setReady(drawing);
+    drawing.hide();
+
+    expect(drawing.getRenderData()).toBeNull();
+  });
+});
+
+describe('RayPaneRenderer', () => {
+  it('не должен рисовать без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new RayPaneRenderer(createRendererDrawing(null)).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать ray и text', () => {
+    const data: RayRenderData = {
+      startPoint: { x: 10, y: 20 },
+      directionPoint: { x: 50, y: 40 },
+      rayEndPoint: { x: 100, y: 65 },
+      left: 10,
+      right: 100,
+      top: 20,
+      bottom: 65,
+      lineColor: '#ff0000',
+      text: 'Ray',
+      fontSize: 14,
+      isBold: true,
+      isItalic: true,
+      textColor: '#ffffff',
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new RayPaneRenderer(createRendererDrawing(data)).draw(target);
+
+    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
+    expect(context.lineTo).toHaveBeenCalledWith(200, 130);
+    expect(context.fillText).toHaveBeenCalled();
+    expect(context.translate).toHaveBeenCalled();
+    expect(context.rotate).toHaveBeenCalled();
+  });
+});
+
+describe('RayPaneView', () => {
+  it('должен возвращать renderer и top', () => {
+    const view = new RayPaneView(createRendererDrawing(null));
+
+    expect(view.renderer()).toBeInstanceOf(RayPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('Ray settings', () => {
+  it('должен создавать defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#2962ff',
+      text: '',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#2962ff',
+    });
+  });
+
+  it('должен создавать style и text tabs', () => {
+    expect(getRaySettingTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/rectangle.test.ts b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20cc89b7046282027b524310c7d6e1fd289895d1
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
@@ -0,0 +1,412 @@
+import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
+import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
+import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
+import { createDefaultSettings, getRectangleSettingsTabs } from '@core/Drawings/rectangle/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  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: {
+      rectangleBorderFill: '#2962ff',
+      rectangleAreaFill: '#2962ff22',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      chartPriceLineText: '#ffffff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestRectangle extends Rectangle {
+  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);
+  }
+
+  public doubleClick(event: MouseEvent): void {
+    this.handleDoubleClick(event);
+  }
+}
+
+function createRectangle(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const rectangle = new TestRectangle({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return { ...env, rectangle, removeSelf, openSettings };
+}
+
+function setReady(rectangle: TestRectangle): void {
+  rectangle.setState({
+    mode: 'ready',
+    startTime: 50 as Time,
+    endTime: 150 as Time,
+    startPrice: 50,
+    endPrice: 150,
+  });
+}
+
+describe('Rectangle', () => {
+  it('прикрепляет primitive', () => {
+    const { rectangle, series } = createRectangle();
+
+    expect(series.attachPrimitive).toHaveBeenCalledWith(rectangle);
+  });
+
+  it('создаётся двумя нажатиями', () => {
+    const { rectangle, container } = createRectangle();
+
+    rectangle.pointerDown(createPointerEvent(container, 50, 50));
+    rectangle.pointerDown(createPointerEvent(container, 150, 150));
+
+    expect(rectangle.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 50,
+      endTime: 150,
+      startPrice: 50,
+      endPrice: 150,
+    });
+  });
+
+  it('удаляет слишком маленький прямоугольник', () => {
+    const { rectangle, container, removeSelf } = createRectangle();
+
+    rectangle.pointerDown(createPointerEvent(container, 50, 50));
+    rectangle.pointerDown(createPointerEvent(container, 54, 54));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('возвращает геометрию', () => {
+    const { rectangle } = createRectangle();
+
+    setReady(rectangle);
+
+    expect(rectangle.getRenderData()).toMatchObject({
+      left: 50,
+      right: 150,
+      top: 50,
+      bottom: 150,
+      width: 100,
+      height: 100,
+    });
+  });
+
+  it('возвращает восемь handles правильного типа', () => {
+    const { rectangle } = createRectangle();
+
+    setReady(rectangle);
+
+    expect(rectangle.handles()).toEqual([
+      { id: 'w', x: 50, y: 100, shape: 'rounded' },
+      { id: 'sw', x: 50, y: 150, shape: 'circle' },
+      { id: 's', x: 100, y: 150, shape: 'rounded' },
+      { id: 'se', x: 150, y: 150, shape: 'circle' },
+      { id: 'e', x: 150, y: 100, shape: 'rounded' },
+      { id: 'ne', x: 150, y: 50, shape: 'circle' },
+      { id: 'n', x: 100, y: 50, shape: 'rounded' },
+      { id: 'nw', x: 50, y: 50, shape: 'circle' },
+    ]);
+  });
+
+  it.each([
+    ['nw', 50, 50, 'nwse-resize'],
+    ['se', 150, 150, 'nwse-resize'],
+    ['ne', 150, 50, 'nesw-resize'],
+    ['sw', 50, 150, 'nesw-resize'],
+    ['n', 100, 50, 'ns-resize'],
+    ['s', 100, 150, 'ns-resize'],
+    ['e', 150, 100, 'ew-resize'],
+    ['w', 50, 100, 'ew-resize'],
+  ])('возвращает корректный cursor для %s', (_, x, y, cursor) => {
+    const { rectangle } = createRectangle(true);
+
+    setReady(rectangle);
+
+    expect(rectangle.hovered(x as number, y as number)?.cursorStyle).toBe(cursor);
+  });
+
+  it('возвращает pointer для невыбранного rectangle', () => {
+    const { rectangle } = createRectangle();
+
+    setReady(rectangle);
+
+    expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('pointer');
+  });
+
+  it('возвращает grab внутри выбранного rectangle', () => {
+    const { rectangle } = createRectangle(true);
+
+    setReady(rectangle);
+
+    expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('grab');
+  });
+
+  it('возвращает axis segments для selected rectangle', () => {
+    const { rectangle } = createRectangle(true);
+
+    setReady(rectangle);
+
+    expect(rectangle.timeSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
+    expect(rectangle.priceSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
+  });
+
+  it('не возвращает axis segments для ready unselected rectangle', () => {
+    const { rectangle } = createRectangle();
+
+    setReady(rectangle);
+
+    expect(rectangle.timeSegments()).toEqual([]);
+    expect(rectangle.priceSegments()).toEqual([]);
+  });
+
+  it('возвращает axis labels', () => {
+    const { rectangle } = createRectangle(true);
+
+    setReady(rectangle);
+
+    expect(rectangle.timeLabel('left')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(rectangle.timeLabel('right')).toMatchObject({
+      coordinate: 150,
+      text: 'date:150',
+    });
+
+    expect(rectangle.priceLabel('top')).toMatchObject({
+      coordinate: 50,
+      text: 'price:50',
+    });
+
+    expect(rectangle.priceLabel('bottom')).toMatchObject({
+      coordinate: 150,
+      text: 'price:150',
+    });
+  });
+
+  it('выбирает rectangle первым нажатием', () => {
+    const { rectangle, interaction, container } = createRectangle();
+
+    setReady(rectangle);
+    rectangle.pointerDown(createPointerEvent(container, 100, 100));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('изменяет левую границу через w handle', () => {
+    const { rectangle, container } = createRectangle(true);
+
+    setReady(rectangle);
+
+    rectangle.pointerDown(createPointerEvent(container, 50, 100));
+    rectangle.pointerMove(createPointerEvent(container, 30, 100));
+    rectangle.pointerUp(createPointerEvent(container, 30, 100));
+
+    expect(rectangle.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 30,
+      endTime: 150,
+    });
+  });
+
+  it('изменяет верхний левый угол', () => {
+    const { rectangle, container } = createRectangle(true);
+
+    setReady(rectangle);
+
+    rectangle.pointerDown(createPointerEvent(container, 50, 50));
+    rectangle.pointerMove(createPointerEvent(container, 30, 20));
+
+    expect(rectangle.getRenderData()).toMatchObject({
+      left: 30,
+      top: 20,
+      right: 150,
+      bottom: 150,
+    });
+  });
+
+  it('перемещает rectangle целиком', () => {
+    const { rectangle, container } = createRectangle(true);
+
+    setReady(rectangle);
+
+    rectangle.pointerDown(createPointerEvent(container, 100, 100));
+    rectangle.pointerMove(createPointerEvent(container, 120, 130));
+
+    expect(rectangle.getState()).toMatchObject({
+      startTime: 70,
+      endTime: 170,
+      startPrice: 80,
+      endPrice: 180,
+    });
+  });
+
+  it('ограничивает перемещение границами контейнера', () => {
+    const { rectangle, container } = createRectangle(true);
+
+    setReady(rectangle);
+
+    rectangle.pointerDown(createPointerEvent(container, 100, 100));
+    rectangle.pointerMove(createPointerEvent(container, -500, -500));
+
+    expect(rectangle.getRenderData()).toMatchObject({
+      left: 0,
+      top: 0,
+      right: 100,
+      bottom: 100,
+    });
+  });
+
+  it('открывает settings по double click', () => {
+    const { rectangle, container, openSettings } = createRectangle();
+
+    setReady(rectangle);
+    rectangle.doubleClick(createMouseEvent(container, 100, 100));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не возвращает render data когда hidden', () => {
+    const { rectangle } = createRectangle();
+
+    setReady(rectangle);
+    rectangle.hide();
+
+    expect(rectangle.getRenderData()).toBeNull();
+  });
+});
+
+describe('RectanglePaneRenderer', () => {
+  it('ничего не рисует без render data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+    const renderer = new RectanglePaneRenderer({
+      getRenderData: () => null,
+    } as unknown as Rectangle);
+
+    renderer.draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует fill, border и text', () => {
+    const data: RectangleRenderData = {
+      left: 10,
+      right: 100,
+      top: 20,
+      bottom: 80,
+      width: 90,
+      height: 60,
+      borderColor: '#fff',
+      fillColor: '#111',
+      text: 'Rectangle',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#fff',
+    };
+
+    const { target, context } = createCanvasTarget();
+    const renderer = new RectanglePaneRenderer({
+      getRenderData: () => data,
+    } as unknown as Rectangle);
+
+    renderer.draw(target);
+
+    expect(context.fillRect).toHaveBeenCalled();
+    expect(context.strokeRect).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalledWith('Rectangle', 20, expect.any(Number));
+  });
+});
+
+describe('RectanglePaneView', () => {
+  it('возвращает renderer и top zOrder', () => {
+    const view = new RectanglePaneView({} as Rectangle);
+
+    expect(view.renderer()).toBeInstanceOf(RectanglePaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('Rectangle settings', () => {
+  it('создаёт default settings', () => {
+    expect(createDefaultSettings()).toEqual({
+      borderColor: '#2962ff',
+      fillColor: '#2962ff22',
+      text: '',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#2962ff',
+    });
+  });
+
+  it('создаёт style и text tabs', () => {
+    expect(getRectangleSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fa6052badb1b3823afe20acee42f9a19e8d4a1e3
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
@@ -0,0 +1,328 @@
+import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
+import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
+import { RegressionTrend, type RegressionTrendRenderData } from '@core/Drawings/regressionTrend/regressionTrend';
+import { createDefaultSettings, getRegressionTrendSettingsTabs } from '@core/Drawings/regressionTrend/settings';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  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: {
+      chartLineColor: '#2962ff',
+      chartCrosshairLine: '#999999',
+      sliderPositiveFill: '#00aa0044',
+      sliderNegativeFill: '#aa000044',
+      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 seriesData = [
+  { time: 10 as Time, close: 10 },
+  { time: 20 as Time, close: 20 },
+  { time: 30 as Time, close: 15 },
+  { time: 40 as Time, close: 30 },
+];
+
+class TestRegressionTrend extends RegressionTrend {
+  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);
+  }
+
+  public doubleClick(event: MouseEvent): void {
+    this.handleDoubleClick(event);
+  }
+}
+
+function createRegression(selected = false) {
+  const env = createDrawingTestEnvironment({ selected, seriesData });
+  const openSettings = jest.fn();
+
+  const regression = new TestRegressionTrend({
+    ...env,
+    openSettings,
+  });
+
+  regression.setState({
+    mode: 'ready',
+    startTime: 10 as Time,
+    endTime: 40 as Time,
+  });
+
+  return { ...env, regression, openSettings };
+}
+
+describe('RegressionTrend', () => {
+  it('рассчитывает linear regression', () => {
+    const { regression } = createRegression();
+    const data = regression.getRenderData();
+
+    expect(data?.baseStartPrice).toBeCloseTo(10.5);
+    expect(data?.baseEndPrice).toBeCloseTo(27);
+    expect(data?.correlation).toBeCloseTo(0.8315218406);
+  });
+
+  it('строит upper и lower channel на двух отклонениях', () => {
+    const { regression } = createRegression();
+    const data = regression.getRenderData();
+
+    const deviation = 4.1079191813;
+
+    expect(data?.upperStartPoint.y).toBeCloseTo(10.5 + deviation * 2);
+    expect(data?.lowerStartPoint.y).toBeCloseTo(10.5 - deviation * 2);
+    expect(data?.upperEndPoint.y).toBeCloseTo(27 + deviation * 2);
+    expect(data?.lowerEndPoint.y).toBeCloseTo(27 - deviation * 2);
+  });
+
+  it('возвращает два handles на base line', () => {
+    const { regression } = createRegression();
+
+    expect(regression.handles()).toEqual([
+      {
+        id: 'start',
+        x: 10,
+        y: 10.5,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'end',
+        x: 40,
+        y: 27,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ]);
+  });
+
+  it('показывает channel в ready', () => {
+    const { regression } = createRegression();
+
+    expect(regression.getRenderData()?.showChannel).toBe(true);
+  });
+
+  it('не показывает channel во время drawing', () => {
+    const { regression } = createRegression();
+
+    regression.setState({ mode: 'drawing' });
+
+    expect(regression.getRenderData()?.showChannel).toBe(false);
+  });
+
+  it('нормализует dragging state в ready при setState', () => {
+    const { regression } = createRegression();
+
+    regression.setState({ mode: 'dragging' });
+
+    expect(regression.getState().mode).toBe('ready');
+  });
+
+  it('возвращает axis segments', () => {
+    const { regression } = createRegression(true);
+
+    expect(regression.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
+    expect(regression.priceSegments()).toEqual([{ from: 10.5, to: 27, color: '#2962ff44' }]);
+  });
+
+  it('возвращает axis labels', () => {
+    const { regression } = createRegression(true);
+
+    expect(regression.timeLabel('start')).toMatchObject({
+      coordinate: 10,
+      text: 'date:10',
+    });
+
+    expect(regression.priceLabel('end')).toMatchObject({
+      coordinate: 27,
+      text: 'price:27',
+    });
+  });
+
+  it('возвращает pointer для unselected channel', () => {
+    const { regression } = createRegression();
+
+    expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('pointer');
+  });
+
+  it('возвращает ew-resize для selected handle', () => {
+    const { regression } = createRegression(true);
+
+    expect(regression.hovered(10, 10.5)?.cursorStyle).toBe('ew-resize');
+  });
+
+  it('возвращает grab для selected body', () => {
+    const { regression } = createRegression(true);
+
+    expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('grab');
+  });
+
+  it('перемещает start edge на ближайший bar', () => {
+    const { regression, container } = createRegression(true);
+
+    regression.pointerDown(createPointerEvent(container, 10, 10.5));
+    regression.pointerMove(createPointerEvent(container, 20, 20));
+    regression.pointerUp(createPointerEvent(container, 20, 20));
+
+    expect(regression.getState()).toMatchObject({
+      mode: 'ready',
+      startTime: 20,
+      endTime: 40,
+    });
+  });
+
+  it('не позволяет схлопнуть range до одного bar', () => {
+    const { regression, container } = createRegression(true);
+
+    regression.pointerDown(createPointerEvent(container, 10, 10.5));
+    regression.pointerMove(createPointerEvent(container, 40, 30));
+
+    expect(regression.getState().startTime).toBe(10);
+  });
+
+  it('открывает настройки только для selected trend', () => {
+    const { regression, container, openSettings } = createRegression(true);
+
+    regression.doubleClick(createMouseEvent(container, 25, 18.75));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('возвращает null когда hidden', () => {
+    const { regression } = createRegression();
+
+    regression.hide();
+
+    expect(regression.getRenderData()).toBeNull();
+  });
+});
+
+describe('RegressionTrendPaneRenderer', () => {
+  it('ничего не рисует без данных', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+    const renderer = new RegressionTrendPaneRenderer({
+      getRenderData: () => null,
+    } as unknown as RegressionTrend);
+
+    renderer.draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует channel, middle line и correlation', () => {
+    const data: RegressionTrendRenderData = {
+      baseStartPoint: { x: 10, y: 20 },
+      baseEndPoint: { x: 100, y: 40 },
+      upperStartPoint: { x: 10, y: 10 },
+      upperEndPoint: { x: 100, y: 30 },
+      lowerStartPoint: { x: 10, y: 30 },
+      lowerEndPoint: { x: 100, y: 50 },
+      baseStartPrice: 20,
+      baseEndPrice: 40,
+      correlation: 0.9,
+      left: 10,
+      right: 100,
+      top: 10,
+      bottom: 50,
+      lineColor: '#fff',
+      middleLineColor: '#aaa',
+      upperFillColor: '#0f03',
+      lowerFillColor: '#f003',
+      showMiddleLine: true,
+      showCorrelation: true,
+      showChannel: true,
+    };
+
+    const { target, context } = createCanvasTarget();
+    const renderer = new RegressionTrendPaneRenderer({
+      getRenderData: () => data,
+    } as unknown as RegressionTrend);
+
+    renderer.draw(target);
+
+    expect(context.fill).toHaveBeenCalledTimes(2);
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.setLineDash).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalledWith('0.9', expect.any(Number), expect.any(Number));
+  });
+});
+
+describe('RegressionTrendPaneView', () => {
+  it('возвращает renderer и top', () => {
+    const view = new RegressionTrendPaneView({} as RegressionTrend);
+
+    expect(view.renderer()).toBeInstanceOf(RegressionTrendPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('RegressionTrend settings', () => {
+  it('создаёт defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#2962ff',
+      middleLineColor: '#999999',
+      upperFillColor: '#00aa0044',
+      lowerFillColor: '#aa000044',
+      showMiddleLine: true,
+      showCorrelation: true,
+    });
+  });
+
+  it('имеет только style tab', () => {
+    expect(getRegressionTrendSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/ruler.test.ts b/src/core/Drawings/__tests__/drawings/ruler.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..69ebc65d964bcdcc12037333d609aa4e8bb2a976
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/ruler.test.ts
@@ -0,0 +1,290 @@
+import { BehaviorSubject } from 'rxjs';
+
+import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
+import { RulerPaneView } from '@core/Drawings/ruler/paneView';
+import { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';
+
+import { Direction } from '@src/types';
+
+import { createCanvasTarget, createDrawingTestEnvironment } from '../drawingTestUtils';
+
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartLineColor: '#00aa00',
+      chartLineColorAlternative: '#aa0000',
+      rulerPositiveFill: '#00aa0044',
+      rulerNegativeFill: '#aa000044',
+      chartPriceLineText: '#ffffff',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => `price:${value}`,
+  formatVolume: (value: number) => `volume:${value}`,
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+const seriesData = [
+  { time: 10 as Time, close: 10, volume: 100 },
+  { time: 20 as Time, close: 20, customValues: { volume: 200 } },
+  { time: 30 as Time, close: 30, volume: 300 },
+];
+
+class TestRuler extends Ruler {
+  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);
+  }
+}
+
+function createRuler() {
+  const env = createDrawingTestEnvironment({ seriesData });
+  const removeSelf = jest.fn();
+
+  const ruler = new TestRuler({
+    ...env,
+    removeSelf,
+  });
+
+  return { ...env, ruler, removeSelf };
+}
+
+function setReady(ruler: TestRuler, startPrice = 10, endPrice = 30): void {
+  ruler.setState({
+    mode: 'ready',
+    startAnchor: { time: 10 as Time, price: startPrice },
+    endAnchor: { time: 30 as Time, price: endPrice },
+  });
+}
+
+describe('Ruler', () => {
+  it('не отображается в Object Tree', () => {
+    const { ruler } = createRuler();
+
+    expect(ruler.shouldShowInObjectTree()).toBe(false);
+  });
+
+  it('не имеет settings tabs', () => {
+    const { ruler } = createRuler();
+
+    expect(ruler.getSettingsTabs()).toEqual([]);
+  });
+
+  it('считает изменение цены, процент, bars и volume', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.getRenderData().infoLines).toEqual(['price:20 (price:200%)', '2 bars,', 'Vol volume:600']);
+  });
+
+  it('берёт volume из customValues', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.getRenderData().infoLines[2]).toBe('Vol volume:600');
+  });
+
+  it('выбирает positive цвета для роста', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler, 10, 30);
+
+    expect(ruler.getRenderData()).toMatchObject({
+      lineColor: '#00aa00',
+      fillColor: '#00aa0044',
+    });
+  });
+
+  it('выбирает negative цвета для падения', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler, 30, 10);
+
+    expect(ruler.getRenderData()).toMatchObject({
+      lineColor: '#aa0000',
+      fillColor: '#aa000044',
+    });
+  });
+
+  it('определяет направление стрелок', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.getRenderData()).toMatchObject({
+      horizontalArrowSide: Direction.Right,
+      verticalArrowSide: Direction.Bottom,
+    });
+  });
+
+  it('возвращает axis bounds', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.getTimeBounds()).toEqual({ left: 10, right: 30 });
+    expect(ruler.getPriceBounds()).toEqual({ top: 10, bottom: 30 });
+  });
+
+  it('возвращает axis segments', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.timeSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
+    expect(ruler.priceSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
+  });
+
+  it('возвращает axis labels', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.timeLabel('start')).toMatchObject({
+      coordinate: 10,
+      text: 'date:10',
+    });
+
+    expect(ruler.priceLabel('end')).toMatchObject({
+      coordinate: 30,
+      text: 'price:30',
+    });
+  });
+
+  it('возвращает autoscaleInfo когда ruler входит в logical range', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.autoscaleInfo(0 as never, 50 as never)).toEqual({
+      priceRange: {
+        minValue: 10,
+        maxValue: 30,
+      },
+    });
+  });
+
+  it('не влияет на autoscale вне logical range', () => {
+    const { ruler } = createRuler();
+
+    setReady(ruler);
+
+    expect(ruler.autoscaleInfo(100 as never, 200 as never)).toBeNull();
+  });
+
+  it('не влияет на autoscale во время placingEnd', () => {
+    const { ruler } = createRuler();
+
+    ruler.setState({
+      mode: 'placingEnd',
+      startAnchor: { time: 10 as Time, price: 10 },
+      endAnchor: { time: 30 as Time, price: 30 },
+    });
+
+    expect(ruler.autoscaleInfo(0 as never, 50 as never)).toBeNull();
+  });
+
+  it('вызывает removeSelf после reset trigger', () => {
+    const reset$ = new BehaviorSubject(false);
+    const env = createDrawingTestEnvironment({ seriesData });
+    const removeSelf = jest.fn();
+
+    const ruler = new TestRuler({
+      ...env,
+      removeSelf,
+      resetTriggers: [reset$],
+    });
+
+    expect(removeSelf).not.toHaveBeenCalled();
+
+    reset$.next(true);
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+
+    ruler.destroy();
+  });
+});
+
+describe('RulerPaneRenderer', () => {
+  it('не рисует hidden ruler', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+    const renderer = new RulerPaneRenderer({
+      getRenderData: () => ({
+        hidden: true,
+        startPoint: null,
+        endPoint: null,
+      }),
+    } as unknown as Ruler);
+
+    renderer.draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует область, стрелки и info lines', () => {
+    const data: RulerRenderData = {
+      hidden: false,
+      startPoint: { x: 10, y: 20 },
+      endPoint: { x: 100, y: 80 },
+      lineColor: '#fff',
+      fillColor: '#111',
+      textColor: '#fff',
+      infoLines: ['one', 'two', 'three'],
+      horizontalArrowSide: Direction.Right,
+      verticalArrowSide: Direction.Bottom,
+    };
+
+    const { target, context } = createCanvasTarget();
+    const renderer = new RulerPaneRenderer({
+      getRenderData: () => data,
+    } as unknown as Ruler);
+
+    renderer.draw(target);
+
+    expect(context.fillRect).toHaveBeenCalled();
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalledTimes(3);
+  });
+});
+
+describe('RulerPaneView', () => {
+  it('возвращает renderer и top', () => {
+    const view = new RulerPaneView({} as Ruler);
+
+    expect(view.renderer()).toBeInstanceOf(RulerPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1338dfe9176256e4f5a332459df168b3a21e58cf
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
@@ -0,0 +1,446 @@
+import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
+import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
+import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
+import { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';
+
+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: {
+      chartCrosshairLine: '#999999',
+      sliderPositiveFill: '#00aa0044',
+      sliderNegativeFill: '#aa000044',
+      chartPriceLineText: '#ffffff',
+      chartCandleUp: '#00aa00',
+      chartCandleDown: '#aa0000',
+      axisMarkerAreaFill: '#2962ff44',
+      axisMarkerLabelFill: '#2962ff',
+      axisMarkerLabelDefaultFill: '#777777',
+      axisMarkerLabelPositiveFill: '#00aa00',
+      axisMarkerLabelNegativeFill: '#aa0000',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatPrice: (value: number) => String(value),
+  formatPercent: (value: number) => `${value}%`,
+  formatSignedNumber: (value: number) => String(value),
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+  formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestSliderPosition extends SliderPosition {
+  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 createSlider(side: 'long' | 'short' = 'long', selected = false) {
+  const seriesData = [
+    { time: 10 as Time, close: 100 },
+    { time: 100 as Time, close: 110 },
+  ];
+
+  const env = createDrawingTestEnvironment({ selected, seriesData });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const slider = new TestSliderPosition({
+    ...env,
+    side,
+    removeSelf,
+    openSettings,
+  });
+
+  return { ...env, slider, removeSelf, openSettings };
+}
+
+function setReady(slider: TestSliderPosition): void {
+  slider.setState({
+    mode: 'ready',
+    startTime: 10 as Time,
+    endTime: 100 as Time,
+    entryPrice: 100,
+    stopPrice: 90,
+    targetPrice: 120,
+    amount: 1000,
+    tickSize: 1,
+  });
+}
+
+describe('SliderPosition', () => {
+  it('считает Risk/Reward Ratio', () => {
+    const { slider } = createSlider();
+
+    setReady(slider);
+
+    expect(slider.getState().riskRewardRatio).toBe(2);
+  });
+
+  it('поддерживает RR = 0', () => {
+    const { slider } = createSlider();
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 90,
+      riskRewardRatio: 0,
+    });
+
+    expect(slider.getState()).toMatchObject({
+      targetPrice: 100,
+      riskRewardRatio: 0,
+    });
+  });
+
+  it('возвращает RR = 0 когда Target совпадает с Entry', () => {
+    const { slider } = createSlider();
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 90,
+      targetPrice: 100,
+    });
+
+    expect(slider.getState().riskRewardRatio).toBe(0);
+  });
+
+  it('возвращает RR = 0 когда Stop совпадает с Entry', () => {
+    const { slider } = createSlider();
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 100,
+      targetPrice: 120,
+    });
+
+    expect(slider.getState().riskRewardRatio).toBe(0);
+  });
+
+  it('возвращает четыре handles в правильном приоритетном порядке', () => {
+    const { slider } = createSlider();
+
+    setReady(slider);
+
+    expect(slider.handles()).toEqual([
+      { id: 'target', x: 10, y: 120 },
+      { id: 'stop', x: 10, y: 90 },
+      { id: 'end', x: 100, y: 100 },
+      { id: 'entry', x: 10, y: 100, shape: 'circle' },
+    ]);
+  });
+
+  it('Entry handle имеет приоритет над Target при совпадении', () => {
+    const { slider, container } = createSlider('long', true);
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 90,
+      targetPrice: 100,
+    });
+
+    slider.pointerDown(createPointerEvent(container, 10, 100));
+    slider.pointerMove(createPointerEvent(container, 10, 95));
+
+    expect(slider.getState()).toMatchObject({
+      entryPrice: 95,
+      targetPrice: 100,
+    });
+  });
+
+  it('Entry handle имеет приоритет над Stop при совпадении', () => {
+    const { slider, container } = createSlider('long', true);
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 100,
+      targetPrice: 120,
+    });
+
+    slider.pointerDown(createPointerEvent(container, 10, 100));
+    slider.pointerMove(createPointerEvent(container, 10, 110));
+
+    expect(slider.getState()).toMatchObject({
+      entryPrice: 110,
+      stopPrice: 100,
+    });
+  });
+
+  it('Entry нельзя вывести за Stop и Target', () => {
+    const { slider, container } = createSlider('long', true);
+
+    setReady(slider);
+
+    slider.pointerDown(createPointerEvent(container, 10, 100));
+    slider.pointerMove(createPointerEvent(container, 10, 200));
+
+    expect(slider.getState().entryPrice).toBe(120);
+  });
+
+  it('Stop можно схлопнуть с Entry', () => {
+    const { slider, container } = createSlider('long', true);
+
+    setReady(slider);
+
+    slider.pointerDown(createPointerEvent(container, 10, 90));
+    slider.pointerMove(createPointerEvent(container, 10, 100));
+
+    expect(slider.getState().stopPrice).toBe(100);
+  });
+
+  it('Target можно схлопнуть с Entry', () => {
+    const { slider, container } = createSlider('long', true);
+
+    setReady(slider);
+
+    slider.pointerDown(createPointerEvent(container, 10, 120));
+    slider.pointerMove(createPointerEvent(container, 10, 100));
+
+    expect(slider.getState().targetPrice).toBe(100);
+  });
+
+  it('работает для short позиции', () => {
+    const { slider } = createSlider('short');
+
+    slider.setState({
+      mode: 'ready',
+      startTime: 10 as Time,
+      endTime: 100 as Time,
+      entryPrice: 100,
+      stopPrice: 110,
+      targetPrice: 80,
+    });
+
+    expect(slider.getState().riskRewardRatio).toBe(2);
+    expect(slider.getRenderData()).toMatchObject({
+      targetLabelDirection: 'down',
+      stopLabelDirection: 'up',
+    });
+  });
+
+  it('возвращает selected axis areas', () => {
+    const { slider } = createSlider('long', true);
+
+    setReady(slider);
+
+    expect(slider.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
+    expect(slider.priceSegments()).toEqual([
+      { from: 100, to: 120, color: '#2962ff44' },
+      { from: 90, to: 100, color: '#2962ff44' },
+    ]);
+  });
+
+  it('использует normal zOrder для selected axis pane areas', () => {
+    const { slider } = createSlider();
+
+    expect(slider.timeAxisPaneViews()[0].zOrder?.()).toBe('normal');
+    expect(slider.priceAxisPaneViews()[0].zOrder?.()).toBe('normal');
+  });
+
+  it('time labels показываются только selected drawing', () => {
+    const { slider } = createSlider();
+
+    setReady(slider);
+
+    expect(slider.timeLabel('start')).toBeNull();
+  });
+
+  it('price labels доступны независимо от selection', () => {
+    const { slider } = createSlider();
+
+    setReady(slider);
+
+    expect(slider.priceLabel('target')).toMatchObject({
+      coordinate: 120,
+      text: '120',
+      backgroundColor: '#00aa00',
+    });
+
+    expect(slider.priceLabel('entry')).toMatchObject({
+      coordinate: 100,
+      backgroundColor: '#777777',
+    });
+
+    expect(slider.priceLabel('stop')).toMatchObject({
+      coordinate: 90,
+      backgroundColor: '#aa0000',
+    });
+  });
+
+  it('возвращает правильные cursors', () => {
+    const { slider } = createSlider('long', true);
+
+    setReady(slider);
+
+    expect(slider.hovered(10, 120)?.cursorStyle).toBe('ns-resize');
+    expect(slider.hovered(10, 90)?.cursorStyle).toBe('ns-resize');
+    expect(slider.hovered(100, 100)?.cursorStyle).toBe('ew-resize');
+    expect(slider.hovered(10, 100)?.cursorStyle).toBe('grab');
+  });
+
+  it('выбирает unselected slider по body', () => {
+    const { slider, interaction, container } = createSlider();
+
+    setReady(slider);
+    slider.pointerDown(createPointerEvent(container, 50, 100));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('изменяет end time', () => {
+    const { slider, container } = createSlider('long', true);
+
+    setReady(slider);
+
+    slider.pointerDown(createPointerEvent(container, 100, 100));
+    slider.pointerMove(createPointerEvent(container, 150, 100));
+
+    expect(slider.getState().endTime).toBe(150);
+  });
+});
+
+describe('SliderPaneRenderer', () => {
+  it('не рисует без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new SliderPaneRenderer({
+      getRenderData: () => null,
+    } as unknown as SliderPosition).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует profit/loss и labels', () => {
+    const data: SliderRenderData = {
+      startX: 10,
+      endX: 100,
+      leftX: 10,
+      rightX: 100,
+      entryY: 100,
+      stopY: 120,
+      targetY: 80,
+      entryPrice: 100,
+      stopPrice: 90,
+      targetPrice: 120,
+      profitTop: 80,
+      profitBottom: 100,
+      lossTop: 100,
+      lossBottom: 120,
+      targetText: 'target',
+      centerText: 'center',
+      stopText: 'stop',
+      centerBoxColor: '#0a0',
+      targetLabelDirection: 'up',
+      stopLabelDirection: 'down',
+      showLabels: true,
+      lineColor: '#fff',
+      positiveFillColor: '#0f03',
+      negativeFillColor: '#f003',
+      textColor: '#fff',
+      fontSize: 10,
+      isBold: false,
+      isItalic: false,
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new SliderPaneRenderer({
+      getRenderData: () => data,
+    } as unknown as SliderPosition).draw(target);
+
+    expect(context.fillRect).toHaveBeenCalledTimes(2);
+    expect(context.fillText).toHaveBeenCalled();
+    expect(context.stroke).toHaveBeenCalled();
+  });
+});
+
+describe('SliderPaneView', () => {
+  it('возвращает renderer и top', () => {
+    const view = new SliderPaneView({} as SliderPosition);
+
+    expect(view.renderer()).toBeInstanceOf(SliderPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('SliderPosition settings', () => {
+  it('создаёт defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#999999',
+      positiveFillColor: '#00aa0044',
+      negativeFillColor: '#aa000044',
+      textColor: '#ffffff',
+      fontSize: 10,
+      isBold: false,
+      isItalic: false,
+    });
+  });
+
+  it('создаёт style и text tabs', () => {
+    expect(getSliderPositionSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/text.test.ts b/src/core/Drawings/__tests__/drawings/text.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2eec7aa12b30790be8648b153dd30d4ac8eede21
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/text.test.ts
@@ -0,0 +1,337 @@
+import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
+import { TextPaneView } from '@core/Drawings/text/paneView';
+import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';
+import { Text, type TextRenderData } from '@core/Drawings/text/text';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { AxisLabel } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartPriceLineText: '#ffffff',
+      chartLineColor: '#2962ff',
+      axisMarkerLabelFill: '#2962ff',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+jest.mock('@src/types/defaults', () => ({
+  Defaults: {
+    dateFormat: 'date',
+    timeFormat: '24h',
+    showTime: true,
+  },
+}));
+
+jest.mock('@src/utils', () => ({
+  formatDate: (value: number) => `date:${value}`,
+  formatPrice: (value: number) => `price:${value}`,
+}));
+
+class TestText extends Text {
+  public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+    return this.getHoveredItem(x, y);
+  }
+
+  public timeLabel(kind: string): AxisLabel | null {
+    return this.getTimeAxisLabel(kind);
+  }
+
+  public priceLabel(kind: string): AxisLabel | null {
+    return this.getPriceAxisLabel(kind);
+  }
+
+  public pointerDown(event: PointerEvent): void {
+    this.handlePointerDown(event);
+  }
+
+  public pointerMove(event: PointerEvent): void {
+    this.handlePointerMove(event);
+  }
+
+  public pointerUp(event: PointerEvent): void {
+    this.handlePointerUp(event);
+  }
+
+  public doubleClick(event: MouseEvent): void {
+    this.handleDoubleClick(event);
+  }
+}
+
+let getContextSpy: jest.SpyInstance;
+
+beforeAll(() => {
+  getContextSpy = jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
+    () =>
+      ({
+        measureText: (value: string) => ({ width: value.length * 8 }),
+      }) as unknown as CanvasRenderingContext2D,
+  );
+});
+
+afterAll(() => {
+  getContextSpy.mockRestore();
+});
+
+function createText(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const text = new TestText({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return { ...env, text, removeSelf, openSettings };
+}
+
+function setReady(text: TestText): void {
+  text.setState({
+    mode: 'ready',
+    point: { time: 50 as Time, price: 60 },
+    settings: {
+      ...createDefaultSettings(),
+      text: 'Test',
+    },
+  });
+}
+
+describe('Text', () => {
+  it('создаётся одним pointer down', () => {
+    const { text, container } = createText();
+
+    text.pointerDown(createPointerEvent(container, 50, 60));
+
+    expect(text.getState()).toMatchObject({
+      mode: 'ready',
+      point: {
+        time: 50,
+        price: 60,
+      },
+    });
+  });
+
+  it('правильно измеряет text block', () => {
+    const { text } = createText();
+
+    setReady(text);
+
+    expect(text.getRenderData()).toMatchObject({
+      point: { x: 50, y: 60 },
+      width: 44,
+      height: 26,
+      lines: ['Test'],
+      lineHeight: 14,
+    });
+  });
+
+  it('поддерживает multiline text', () => {
+    const { text } = createText();
+
+    text.setState({
+      mode: 'ready',
+      point: { time: 50 as Time, price: 60 },
+      settings: {
+        ...createDefaultSettings(),
+        text: 'One\nTwo',
+      },
+    });
+
+    expect(text.getRenderData()).toMatchObject({
+      lines: ['One', 'Two'],
+      height: 40,
+    });
+  });
+
+  it('показывает selection border только selected', () => {
+    const { text, setSelected } = createText();
+
+    setReady(text);
+
+    expect(text.getRenderData()?.showSelectionBorder).toBe(false);
+
+    setSelected(true);
+
+    expect(text.getRenderData()?.showSelectionBorder).toBe(true);
+  });
+
+  it('возвращает move cursor внутри текста', () => {
+    const { text } = createText();
+
+    setReady(text);
+
+    expect(text.hovered(60, 70)?.cursorStyle).toBe('move');
+    expect(text.hovered(200, 200)).toBeNull();
+  });
+
+  it('выбирает text первым нажатием', () => {
+    const { text, container, interaction } = createText();
+
+    setReady(text);
+
+    text.pointerDown(createPointerEvent(container, 60, 70));
+
+    expect(interaction.select).toHaveBeenCalledTimes(1);
+  });
+
+  it('перемещает selected text', () => {
+    const { text, container } = createText(true);
+
+    setReady(text);
+
+    text.pointerDown(createPointerEvent(container, 60, 70));
+    text.pointerMove(createPointerEvent(container, 80, 90));
+    text.pointerUp(createPointerEvent(container, 80, 90));
+
+    expect(text.getState()).toMatchObject({
+      mode: 'ready',
+      point: {
+        time: 70,
+        price: 80,
+      },
+    });
+  });
+
+  it('не позволяет text выйти за пределы контейнера', () => {
+    const { text, container } = createText(true);
+
+    setReady(text);
+
+    text.pointerDown(createPointerEvent(container, 60, 70));
+    text.pointerMove(createPointerEvent(container, 1000, 1000));
+
+    const data = text.getRenderData();
+
+    expect(data?.right).toBeLessThanOrEqual(400);
+    expect(data?.bottom).toBeLessThanOrEqual(300);
+  });
+
+  it('показывает axis labels только selected', () => {
+    const { text } = createText(true);
+
+    setReady(text);
+
+    expect(text.timeLabel('main')).toMatchObject({
+      coordinate: 50,
+      text: 'date:50',
+    });
+
+    expect(text.priceLabel('main')).toMatchObject({
+      coordinate: 60,
+      text: 'price:60',
+    });
+  });
+
+  it('не имеет axis pane segments', () => {
+    const { text } = createText(true);
+
+    setReady(text);
+
+    expect(text.timeAxisPaneViews()).toEqual([]);
+    expect(text.priceAxisPaneViews()).toEqual([]);
+  });
+
+  it('открывает settings по double click selected text', () => {
+    const { text, container, openSettings } = createText(true);
+
+    setReady(text);
+    text.doubleClick(createMouseEvent(container, 60, 70));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не возвращает render data когда hidden', () => {
+    const { text } = createText();
+
+    setReady(text);
+    text.hide();
+
+    expect(text.getRenderData()).toBeNull();
+  });
+});
+
+describe('TextPaneRenderer', () => {
+  it('ничего не рисует без данных', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new TextPaneRenderer({
+      getRenderData: () => null,
+    } as unknown as Text).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует background, border, text и selection', () => {
+    const data: TextRenderData = {
+      point: { x: 10, y: 20 },
+      left: 10,
+      right: 80,
+      top: 20,
+      bottom: 60,
+      width: 70,
+      height: 40,
+      lines: ['Test'],
+      font: '14px Inter',
+      lineHeight: 14,
+      text: 'Test',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#fff',
+      backgroundColor: '#111',
+      borderColor: '#222',
+      showSelectionBorder: true,
+    };
+
+    const { target, context } = createCanvasTarget();
+
+    new TextPaneRenderer({
+      getRenderData: () => data,
+    } as unknown as Text).draw(target);
+
+    expect(context.fill).toHaveBeenCalled();
+    expect(context.stroke).toHaveBeenCalled();
+    expect(context.fillText).toHaveBeenCalledWith('Test', expect.any(Number), expect.any(Number));
+  });
+});
+
+describe('TextPaneView', () => {
+  it('переиспользует renderer и возвращает top', () => {
+    const view = new TextPaneView({} as Text);
+    const renderer = view.renderer();
+
+    expect(renderer).toBeInstanceOf(TextPaneRenderer);
+    expect(view.renderer()).toBe(renderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('Text settings', () => {
+  it('создаёт defaults', () => {
+    expect(createDefaultSettings()).toEqual({
+      text: 'Text',
+      fontSize: 14,
+      isBold: false,
+      isItalic: false,
+      textColor: '#ffffff',
+      backgroundColor: '#00000000',
+      borderColor: '#00000000',
+    });
+  });
+
+  it('создаёт style и text tabs', () => {
+    expect(getTextSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/traectory.test.ts b/src/core/Drawings/__tests__/drawings/traectory.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e51234b2f2a6331640df240f23e96c485d5c77b8
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/traectory.test.ts
@@ -0,0 +1,289 @@
+import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
+import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
+import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';
+import { Traectory } from '@core/Drawings/traectory/traectory';
+
+import {
+  createCanvasTarget,
+  createDrawingTestEnvironment,
+  createMouseEvent,
+  createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartLineColor: '#2962ff',
+      axisMarkerAreaFill: '#2962ff44',
+    },
+  }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+class TestTraectory extends Traectory {
+  public handles(): readonly DrawingHandle[] {
+    return this.getDrawingHandles();
+  }
+
+  public timeSegments(): AxisSegment[] {
+    return this.getTimeAxisSegments();
+  }
+
+  public priceSegments(): AxisSegment[] {
+    return this.getPriceAxisSegments();
+  }
+
+  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);
+  }
+
+  public click(event: MouseEvent): void {
+    this.handleClick(event);
+  }
+
+  public doubleClick(event: MouseEvent): void {
+    this.handleDoubleClick(event);
+  }
+
+  public contextMenu(event: MouseEvent): void {
+    this.handleContextMenu(event);
+  }
+}
+
+function createTraectory(selected = false) {
+  const env = createDrawingTestEnvironment({ selected });
+  const removeSelf = jest.fn();
+  const openSettings = jest.fn();
+
+  const traectory = new TestTraectory({
+    ...env,
+    removeSelf,
+    openSettings,
+  });
+
+  return { ...env, traectory, removeSelf, openSettings };
+}
+
+function setReady(traectory: TestTraectory): void {
+  traectory.setState({
+    mode: 'ready',
+    points: [
+      { time: 10 as Time, price: 20 },
+      { time: 50 as Time, price: 60 },
+      { time: 100 as Time, price: 40 },
+    ],
+  });
+}
+
+describe('Traectory', () => {
+  it('начинает drawing первым pointer down', () => {
+    const { traectory, container } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+
+    expect(traectory.getState()).toMatchObject({
+      mode: 'drawing',
+      points: [{ time: 10, price: 20 }],
+    });
+  });
+
+  it('добавляет точки обычным click', () => {
+    const { traectory, container } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+    traectory.click(createMouseEvent(container, 50, 60));
+
+    expect(traectory.getState().points).toEqual([
+      { time: 10, price: 20 },
+      { time: 50, price: 60 },
+    ]);
+  });
+
+  it('не добавляет одну и ту же точку дважды', () => {
+    const { traectory, container } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+    traectory.click(createMouseEvent(container, 10, 20));
+
+    expect(traectory.getState().points).toHaveLength(1);
+  });
+
+  it('заканчивает drawing double click', () => {
+    const { traectory, container } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+    traectory.click(createMouseEvent(container, 50, 60));
+    traectory.doubleClick(createMouseEvent(container, 50, 60, 2));
+
+    expect(traectory.getState().mode).toBe('ready');
+    expect(traectory.getRenderData()?.showArrow).toBe(true);
+  });
+
+  it('заканчивает drawing context menu', () => {
+    const { traectory, container } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+    traectory.click(createMouseEvent(container, 50, 60));
+    traectory.contextMenu(createMouseEvent(container, 50, 60));
+
+    expect(traectory.getState().mode).toBe('ready');
+  });
+
+  it('удаляется при завершении с одной точкой', () => {
+    const { traectory, container, removeSelf } = createTraectory();
+
+    traectory.pointerDown(createPointerEvent(container, 10, 20));
+    traectory.doubleClick(createMouseEvent(container, 10, 20, 2));
+
+    expect(removeSelf).toHaveBeenCalledTimes(1);
+  });
+
+  it('возвращает handles в обратном порядке', () => {
+    const { traectory } = createTraectory();
+
+    setReady(traectory);
+
+    expect(traectory.handles()).toEqual([
+      { id: '2', x: 100, y: 40, shape: 'circle', size: 10, borderWidth: 2 },
+      { id: '1', x: 50, y: 60, shape: 'circle', size: 10, borderWidth: 2 },
+      { id: '0', x: 10, y: 20, shape: 'circle', size: 10, borderWidth: 2 },
+    ]);
+  });
+
+  it('возвращает axis segments', () => {
+    const { traectory } = createTraectory(true);
+
+    setReady(traectory);
+
+    expect(traectory.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
+    expect(traectory.priceSegments()).toEqual([{ from: 20, to: 60, color: '#2962ff44' }]);
+  });
+
+  it('не имеет axis labels', () => {
+    const { traectory } = createTraectory(true);
+
+    setReady(traectory);
+
+    expect(traectory.timeAxisViews()).toEqual([]);
+    expect(traectory.priceAxisViews()).toEqual([]);
+  });
+
+  it('определяет segment как drawing hit', () => {
+    const { traectory } = createTraectory();
+
+    setReady(traectory);
+
+    expect(traectory.hovered(30, 40)?.cursorStyle).toBe('move');
+    expect(traectory.hovered(300, 200)).toBeNull();
+  });
+
+  it('перемещает отдельную точку', () => {
+    const { traectory, container } = createTraectory(true);
+
+    setReady(traectory);
+
+    traectory.pointerDown(createPointerEvent(container, 50, 60));
+    traectory.pointerMove(createPointerEvent(container, 70, 80));
+    traectory.pointerUp(createPointerEvent(container, 70, 80));
+
+    expect(traectory.getState().points[1]).toEqual({
+      time: 70,
+      price: 80,
+    });
+  });
+
+  it('перемещает всю trajectory', () => {
+    const { traectory, container } = createTraectory(true);
+
+    setReady(traectory);
+
+    traectory.pointerDown(createPointerEvent(container, 30, 40));
+    traectory.pointerMove(createPointerEvent(container, 40, 50));
+
+    expect(traectory.getState().points).toEqual([
+      { time: 20, price: 30 },
+      { time: 60, price: 70 },
+      { time: 110, price: 50 },
+    ]);
+  });
+
+  it('открывает settings по double click', () => {
+    const { traectory, container, openSettings } = createTraectory(true);
+
+    setReady(traectory);
+    traectory.doubleClick(createMouseEvent(container, 30, 40, 2));
+
+    expect(openSettings).toHaveBeenCalledTimes(1);
+  });
+
+  it('не возвращает render data когда hidden', () => {
+    const { traectory } = createTraectory();
+
+    setReady(traectory);
+    traectory.hide();
+
+    expect(traectory.getRenderData()).toBeNull();
+  });
+});
+
+describe('TraectoryPaneRenderer', () => {
+  it('не рисует без data', () => {
+    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+    new TraectoryPaneRenderer({
+      getRenderData: () => null,
+    } as unknown as Traectory).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('рисует trajectory', () => {
+    const { traectory } = createTraectory();
+
+    setReady(traectory);
+
+    const { target, context } = createCanvasTarget();
+
+    new TraectoryPaneRenderer(traectory).draw(target);
+
+    expect(context.stroke).toHaveBeenCalled();
+  });
+});
+
+describe('TraectoryPaneView', () => {
+  it('возвращает renderer и top', () => {
+    const view = new TraectoryPaneView({} as Traectory);
+
+    expect(view.renderer()).toBeInstanceOf(TraectoryPaneRenderer);
+    expect(view.zOrder()).toBe('top');
+  });
+});
+
+describe('Traectory settings', () => {
+  it('создаёт default lineColor', () => {
+    expect(createDefaultSettings()).toEqual({
+      lineColor: '#2962ff',
+    });
+  });
+
+  it('имеет один style tab', () => {
+    expect(getTraectorySettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2ddc1db8a04879d941c426ec5c2976c418680364
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
@@ -0,0 +1,450 @@
+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', size: 10, borderWidth: 2 },
+      { id: 'start', x: 10, y: 9, shape: 'circle', size: 10, borderWidth: 2 },
+    ]);
+  });
+
+  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',
+      size: 10,
+      borderWidth: 2,
+    });
+  });
+
+  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']);
+  });
+});
diff --git a/src/core/Drawings/__tests__/handles.test.ts b/src/core/Drawings/__tests__/handles.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e2b36fe857d434ff5223b2e3dcbbe94056e241a
--- /dev/null
+++ b/src/core/Drawings/__tests__/handles.test.ts
@@ -0,0 +1,367 @@
+import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { SeriesApi } from '@core/Drawings/types';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type {
+  IChartApi,
+  IPrimitivePaneRenderer,
+  IPrimitivePaneView,
+  SeriesAttachedParameter,
+  Time,
+} from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+  getThemeStore: () => ({
+    colors: {
+      chartBackground: '#101010',
+      chartLineColor: '#2962ff',
+    },
+  }),
+}));
+
+function createEnvironment(paneIndex = 0, paneCount = 1, paneHeight = 100) {
+  const pane = {
+    paneIndex: jest.fn(() => paneIndex),
+    getHeight: jest.fn(() => paneHeight),
+  };
+
+  const panes = Array.from({ length: paneCount }, (_, index) => {
+    return index === paneIndex ? pane : {};
+  });
+
+  const chart = {
+    panes: jest.fn(() => panes),
+  } as unknown as IChartApi;
+
+  const series = {
+    getPane: jest.fn(() => pane),
+  } as unknown as SeriesApi;
+
+  return { chart, series, pane };
+}
+
+function attach<TId extends string>(primitive: DrawingHandlesPrimitive<TId>, chart: IChartApi, series: SeriesApi) {
+  primitive.attached({
+    chart,
+    series,
+    requestUpdate: jest.fn(),
+  } as unknown as SeriesAttachedParameter<Time>);
+}
+
+function createTarget(horizontalPixelRatio = 2, verticalPixelRatio = 2) {
+  const context = {
+    save: jest.fn(),
+    restore: jest.fn(),
+    beginPath: jest.fn(),
+    arc: jest.fn(),
+    moveTo: jest.fn(),
+    arcTo: jest.fn(),
+    closePath: jest.fn(),
+    fill: jest.fn(),
+    stroke: jest.fn(),
+    fillStyle: '',
+    strokeStyle: '',
+    lineWidth: 0,
+  };
+
+  const useBitmapCoordinateSpace = jest.fn(
+    (
+      callback: (scope: {
+        context: CanvasRenderingContext2D;
+        horizontalPixelRatio: number;
+        verticalPixelRatio: number;
+        bitmapSize: { width: number; height: number };
+      }) => void,
+    ) => {
+      callback({
+        context: context as unknown as CanvasRenderingContext2D,
+        horizontalPixelRatio,
+        verticalPixelRatio,
+        bitmapSize: {
+          width: 400,
+          height: 100,
+        },
+      });
+    },
+  );
+
+  return {
+    context,
+    target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+    useBitmapCoordinateSpace,
+  };
+}
+
+function getView(views: readonly IPrimitivePaneView[]): IPrimitivePaneView {
+  const [view] = views;
+
+  if (!view) {
+    throw new Error('Primitive pane view is not defined');
+  }
+
+  return view;
+}
+
+function getRenderer(views: readonly IPrimitivePaneView[]): IPrimitivePaneRenderer {
+  const renderer = getView(views).renderer();
+
+  if (!renderer) {
+    throw new Error('Primitive pane renderer is not defined');
+  }
+
+  return renderer;
+}
+
+describe('DrawingHandlesPrimitive', () => {
+  it('должен возвращать top zOrder для pane', () => {
+    const primitive = new DrawingHandlesPrimitive(() => []);
+
+    expect(getView(primitive.paneViews()).zOrder?.()).toBe('top');
+  });
+
+  it('должен возвращать top zOrder для time axis', () => {
+    const primitive = new DrawingHandlesPrimitive(() => []);
+
+    expect(getView(primitive.timeAxisPaneViews()).zOrder?.()).toBe('top');
+  });
+
+  it('должен находить handle в зоне hit tolerance', () => {
+    const handle: DrawingHandle<'main'> = {
+      id: 'main',
+      x: 100,
+      y: 50,
+    };
+
+    const primitive = new DrawingHandlesPrimitive(() => [handle]);
+
+    expect(primitive.findHandle({ x: 108, y: 58 })).toBe(handle);
+  });
+
+  it('не должен находить handle за пределами hit tolerance', () => {
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 100,
+        y: 50,
+      },
+    ]);
+
+    expect(primitive.findHandle({ x: 109, y: 50 })).toBeNull();
+  });
+
+  it('должен учитывать размер handle при вычислении hit area', () => {
+    const handle: DrawingHandle<'main'> = {
+      id: 'main',
+      x: 100,
+      y: 50,
+      size: 24,
+    };
+
+    const primitive = new DrawingHandlesPrimitive(() => [handle]);
+
+    expect(primitive.findHandle({ x: 112, y: 50 })).toBe(handle);
+    expect(primitive.findHandle({ x: 113, y: 50 })).toBeNull();
+  });
+
+  it('должен отдавать приоритет последнему handle при пересечении', () => {
+    const handles: DrawingHandle<'first' | 'second'>[] = [
+      {
+        id: 'first',
+        x: 100,
+        y: 50,
+      },
+      {
+        id: 'second',
+        x: 100,
+        y: 50,
+      },
+    ];
+
+    const primitive = new DrawingHandlesPrimitive(() => handles);
+
+    expect(primitive.findHandle({ x: 100, y: 50 })?.id).toBe('second');
+  });
+
+  it('должен определять handle, пересекающий time axis', () => {
+    const { chart, series } = createEnvironment(0, 1, 100);
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 100,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(true);
+  });
+
+  it('не должен считать точку внутри pane попаданием по time axis', () => {
+    const { chart, series } = createEnvironment(0, 1, 100);
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 100,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    expect(primitive.isTimeAxisHit({ x: 50, y: 99 })).toBe(false);
+  });
+
+  it('не должен работать с time axis для pane, который не является последним', () => {
+    const { chart, series } = createEnvironment(0, 2, 100);
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 100,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(false);
+  });
+
+  it('не должен определять time axis hit для handle выше границы pane', () => {
+    const { chart, series } = createEnvironment(0, 1, 100);
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 80,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
+  });
+
+  it('должен рисовать circle по умолчанию', () => {
+    const { context, target } = createTarget();
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 100,
+        y: 50,
+      },
+    ]);
+
+    getRenderer(primitive.paneViews()).draw(target);
+
+    expect(context.arc).toHaveBeenCalledWith(200, 100, 10, 0, Math.PI * 2);
+    expect(context.fill).toHaveBeenCalledTimes(1);
+    expect(context.stroke).toHaveBeenCalledTimes(1);
+    expect(context.fillStyle).toBe('#101010');
+    expect(context.strokeStyle).toBe('#2962ff');
+    expect(context.lineWidth).toBe(4);
+  });
+
+  it('должен рисовать rounded handle', () => {
+    const { context, target } = createTarget();
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 100,
+        y: 50,
+        shape: 'rounded',
+      },
+    ]);
+
+    getRenderer(primitive.paneViews()).draw(target);
+
+    expect(context.arc).not.toHaveBeenCalled();
+    expect(context.arcTo).toHaveBeenCalled();
+    expect(context.closePath).toHaveBeenCalled();
+    expect(context.fill).toHaveBeenCalledTimes(1);
+    expect(context.stroke).toHaveBeenCalledTimes(1);
+  });
+
+  it('должен использовать custom strokeColor и borderWidth', () => {
+    const { context, target } = createTarget();
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 100,
+        y: 50,
+        strokeColor: '#ff0000',
+        borderWidth: 3,
+      },
+    ]);
+
+    getRenderer(primitive.paneViews()).draw(target);
+
+    expect(context.strokeStyle).toBe('#ff0000');
+    expect(context.lineWidth).toBe(6);
+  });
+
+  it('не должен вызывать canvas при отсутствии handles', () => {
+    const { target, useBitmapCoordinateSpace } = createTarget();
+    const primitive = new DrawingHandlesPrimitive(() => []);
+
+    getRenderer(primitive.paneViews()).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен рисовать пересекающую границу часть handle на time axis', () => {
+    const { chart, series } = createEnvironment(0, 1, 100);
+    const { context, target } = createTarget();
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 100,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    getRenderer(primitive.timeAxisPaneViews()).draw(target);
+
+    expect(context.arc).toHaveBeenCalled();
+  });
+
+  it('не должен рисовать handle на time axis для непоследнего pane', () => {
+    const { chart, series } = createEnvironment(0, 2, 100);
+    const { target, useBitmapCoordinateSpace } = createTarget();
+
+    const primitive = new DrawingHandlesPrimitive(() => [
+      {
+        id: 'main',
+        x: 50,
+        y: 100,
+      },
+    ]);
+
+    attach(primitive, chart, series);
+
+    getRenderer(primitive.timeAxisPaneViews()).draw(target);
+
+    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+  });
+
+  it('должен очищать chart и series после detached', () => {
+    const { chart, series } = createEnvironment();
+    const primitive = new DrawingHandlesPrimitive(() => []);
+
+    attach(primitive, chart, series);
+    primitive.detached();
+
+    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
+  });
+});
diff --git a/src/core/Drawings/__tests__/helpers.test.ts b/src/core/Drawings/__tests__/helpers.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a91cba440acff5e0f18eb1244729c63b0fbbcaee
--- /dev/null
+++ b/src/core/Drawings/__tests__/helpers.test.ts
@@ -0,0 +1,354 @@
+import {
+  clampPointToContainer,
+  getAnchorFromPoint,
+  getContainerSize,
+  getPointerPoint,
+  getPriceDelta,
+  getPriceFromYCoordinate,
+  getPriceRangeInContainer,
+  getRawPointerPoint,
+  getTimeFromXCoordinate,
+  getXCoordinateFromTime,
+  getYCoordinateFromPrice,
+  isNearPoint,
+  isPointInBounds,
+  normalizeBounds,
+  shiftTimeByPixels,
+} from '@core/Drawings/helpers';
+
+import type { SeriesApi } from '@core/Drawings/types';
+import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';
+
+function createContainer(width = 400, height = 300, left = 10, top = 20) {
+  const container = document.createElement('div');
+
+  jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+    x: left,
+    y: top,
+    left,
+    top,
+    right: left + width,
+    bottom: top + height,
+    width,
+    height,
+    toJSON: jest.fn(),
+  });
+
+  return container;
+}
+
+function createChart() {
+  const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
+
+  const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
+
+  const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => {
+    return (Number(logical) * 10) as Coordinate;
+  });
+
+  const timeScale = {
+    coordinateToTime,
+    timeToCoordinate,
+    logicalToCoordinate,
+  };
+
+  const chart = {
+    timeScale: jest.fn(() => timeScale),
+  } as unknown as IChartApi;
+
+  return {
+    chart,
+    timeScale,
+  };
+}
+
+function createSeries(data: readonly { time: Time }[] = []) {
+  const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
+
+  const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
+
+  const dataMock = jest.fn(() => data);
+
+  const series = {
+    coordinateToPrice,
+    priceToCoordinate,
+    data: dataMock,
+  } as unknown as SeriesApi;
+
+  return {
+    series,
+    coordinateToPrice,
+    priceToCoordinate,
+    dataMock,
+  };
+}
+
+describe('Drawing helpers', () => {
+  it('должен получать цену из y coordinate', () => {
+    const { series, coordinateToPrice } = createSeries();
+
+    expect(getPriceFromYCoordinate(series, 50)).toBe(50);
+    expect(coordinateToPrice).toHaveBeenCalledWith(50);
+  });
+
+  it('должен получать y coordinate из цены', () => {
+    const { series, priceToCoordinate } = createSeries();
+
+    expect(getYCoordinateFromPrice(series, 100)).toBe(100);
+    expect(priceToCoordinate).toHaveBeenCalledWith(100);
+  });
+
+  it('должен получать время из x coordinate', () => {
+    const { chart, timeScale } = createChart();
+
+    expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
+    expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
+  });
+
+  it('должен возвращать прямую координату времени', () => {
+    const { chart } = createChart();
+
+    expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
+  });
+
+  it('должен возвращать null без series, если координата времени отсутствует', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
+  });
+
+  it('должен проецировать отсутствующее время через ближайшую свечу', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+
+    expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
+    expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
+  });
+
+  it('должен выбирать предыдущую свечу при одинаковом расстоянии', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+
+    expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
+  });
+
+  it('должен использовать первую свечу для времени до диапазона', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+    expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
+  });
+
+  it('должен использовать последнюю свечу для времени после диапазона', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+    expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(10);
+  });
+
+  it('должен возвращать null при отсутствии данных series', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries();
+
+    expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
+  });
+
+  it('должен возвращать null при невозможности спроецировать logical coordinate', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+    timeScale.logicalToCoordinate.mockReturnValue(null);
+
+    const { series } = createSeries([{ time: 10 as Time }]);
+
+    expect(getXCoordinateFromTime(chart, 10 as Time, series)).toBeNull();
+  });
+
+  it('должен получать размеры container', () => {
+    const container = createContainer(500, 250);
+
+    expect(getContainerSize(container)).toEqual({
+      width: 500,
+      height: 250,
+    });
+  });
+
+  it('должен ограничивать point границами container', () => {
+    const container = createContainer(400, 300);
+
+    expect(clampPointToContainer({ x: -20, y: 350 }, container)).toEqual({
+      x: 0,
+      y: 300,
+    });
+  });
+
+  it('должен получать raw pointer point относительно container', () => {
+    const container = createContainer(400, 300, 10, 20);
+
+    const event = {
+      clientX: 110,
+      clientY: 70,
+    } as unknown as MouseEvent;
+
+    expect(getRawPointerPoint(container, event)).toEqual({
+      x: 100,
+      y: 50,
+    });
+  });
+
+  it('должен ограничивать pointer point границами container', () => {
+    const container = createContainer(400, 300, 10, 20);
+
+    const event = {
+      clientX: 500,
+      clientY: -10,
+    } as unknown as MouseEvent;
+
+    expect(getPointerPoint(container, event)).toEqual({
+      x: 400,
+      y: 0,
+    });
+  });
+
+  it('должен определять близость point', () => {
+    expect(isNearPoint({ x: 108, y: 92 }, 100, 100, 8)).toBe(true);
+    expect(isNearPoint({ x: 109, y: 100 }, 100, 100, 8)).toBe(false);
+  });
+
+  it('должен учитывать границу tolerance при проверке близости', () => {
+    expect(isNearPoint({ x: 108, y: 108 }, 100, 100, 8)).toBe(true);
+  });
+
+  it('должен определять point внутри bounds', () => {
+    const bounds = {
+      left: 10,
+      right: 100,
+      top: 20,
+      bottom: 80,
+    };
+
+    expect(isPointInBounds({ x: 50, y: 50 }, bounds)).toBe(true);
+    expect(isPointInBounds({ x: 5, y: 50 }, bounds)).toBe(false);
+    expect(isPointInBounds({ x: 5, y: 50 }, bounds, 5)).toBe(true);
+  });
+
+  it('должен нормализовать и ограничивать bounds', () => {
+    const container = createContainer(400, 300);
+
+    expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
+      left: 0,
+      right: 400,
+      top: 0,
+      bottom: 300,
+    });
+  });
+
+  it('должен смещать время на количество пикселей', () => {
+    const { chart } = createChart();
+
+    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
+  });
+
+  it('должен возвращать null при отсутствии исходной координаты времени', () => {
+    const { chart, timeScale } = createChart();
+
+    timeScale.timeToCoordinate.mockReturnValue(null);
+
+    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
+  });
+
+  it('должен вычислять price delta', () => {
+    const { series } = createSeries();
+
+    expect(getPriceDelta(series, 20, 70)).toBe(50);
+  });
+
+  it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
+    const { series, coordinateToPrice } = createSeries();
+
+    coordinateToPrice.mockReturnValueOnce(null);
+
+    expect(getPriceDelta(series, 20, 70)).toBe(0);
+  });
+
+  it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
+    const { series, coordinateToPrice } = createSeries();
+
+    coordinateToPrice.mockReturnValueOnce(20).mockReturnValueOnce(null);
+
+    expect(getPriceDelta(series, 20, 70)).toBe(0);
+  });
+
+  it('должен вычислять диапазон цены в container', () => {
+    const container = createContainer(400, 300);
+    const { series, coordinateToPrice } = createSeries();
+
+    coordinateToPrice.mockReturnValueOnce(150).mockReturnValueOnce(50);
+
+    expect(getPriceRangeInContainer(series, container)).toEqual({
+      min: 50,
+      max: 150,
+    });
+  });
+
+  it('должен возвращать null диапазона при нулевой высоте', () => {
+    const container = createContainer(400, 0);
+    const { series } = createSeries();
+
+    expect(getPriceRangeInContainer(series, container)).toBeNull();
+  });
+
+  it('должен возвращать null диапазона при отсутствии цены', () => {
+    const container = createContainer(400, 300);
+    const { series, coordinateToPrice } = createSeries();
+
+    coordinateToPrice.mockReturnValueOnce(null);
+
+    expect(getPriceRangeInContainer(series, container)).toBeNull();
+  });
+
+  it('должен создавать anchor из point', () => {
+    const { chart } = createChart();
+    const { series } = createSeries();
+
+    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toEqual({
+      time: 100,
+      price: 200,
+    });
+  });
+
+  it('должен возвращать null anchor при отсутствии времени', () => {
+    const { chart, timeScale } = createChart();
+    const { series } = createSeries();
+
+    timeScale.coordinateToTime.mockReturnValue(null);
+
+    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
+  });
+
+  it('должен возвращать null anchor при отсутствии цены', () => {
+    const { chart } = createChart();
+    const { series, coordinateToPrice } = createSeries();
+
+    coordinateToPrice.mockReturnValue(null);
+
+    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
+  });
+});
diff --git a/src/core/Drawings/__tests__/shortcuts.test.ts b/src/core/Drawings/__tests__/shortcuts.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec941c4692765df0357027330e3fba7ce5c4bce5
--- /dev/null
+++ b/src/core/Drawings/__tests__/shortcuts.test.ts
@@ -0,0 +1,109 @@
+import {
+  DRAWING_KEYBOARD_SHORTCUTS,
+  DRAWING_POINTER_SHORTCUTS,
+  findDrawingPointerShortcut,
+} from '@core/Drawings/shortcuts';
+
+import { DrawingsNames } from '@src/constants';
+import { Keys } from '@src/core/Hotkeys';
+
+import type { MouseEventParams } from 'lightweight-charts';
+
+function createEvent({
+  shiftKey = false,
+  altKey = false,
+  ctrlKey = false,
+  metaKey = false,
+}: {
+  shiftKey?: boolean;
+  altKey?: boolean;
+  ctrlKey?: boolean;
+  metaKey?: boolean;
+}): MouseEventParams {
+  return {
+    sourceEvent: {
+      shiftKey,
+      altKey,
+      ctrlKey,
+      metaKey,
+    },
+  } as unknown as MouseEventParams;
+}
+
+describe('Drawing shortcuts', () => {
+  it('должен содержать keyboard shortcut для trend line', () => {
+    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+      drawingName: DrawingsNames.trendLine,
+      keys: [Keys.alt, Keys.t],
+    });
+  });
+
+  it('должен содержать keyboard shortcut для horizontal line', () => {
+    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+      drawingName: DrawingsNames.horizontalLine,
+      keys: [Keys.alt, Keys.h],
+    });
+  });
+
+  it('должен содержать keyboard shortcut для vertical line', () => {
+    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+      drawingName: DrawingsNames.verticalLine,
+      keys: [Keys.alt, Keys.v],
+    });
+  });
+
+  it('должен содержать keyboard shortcut для fibonacci', () => {
+    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+      drawingName: DrawingsNames.fibonacciRetracement,
+      keys: [Keys.alt, Keys.f],
+    });
+  });
+
+  it('должен содержать keyboard shortcut для rectangle', () => {
+    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+      drawingName: DrawingsNames.rectangle,
+      keys: [Keys.alt, Keys.shift, Keys.r],
+    });
+  });
+
+  it('должен содержать pointer shortcut для ruler', () => {
+    expect(DRAWING_POINTER_SHORTCUTS).toEqual([
+      {
+        drawingName: DrawingsNames.ruler,
+        modifiers: [Keys.shift],
+      },
+    ]);
+  });
+
+  it('должен находить ruler при нажатом Shift', () => {
+    expect(
+      findDrawingPointerShortcut(
+        createEvent({
+          shiftKey: true,
+        }),
+      ),
+    ).toEqual({
+      drawingName: DrawingsNames.ruler,
+      modifiers: [Keys.shift],
+    });
+  });
+
+  it('не должен находить shortcut без Shift', () => {
+    expect(findDrawingPointerShortcut(createEvent({}))).toBeUndefined();
+  });
+
+  it('не должен находить Shift shortcut при дополнительном Alt', () => {
+    expect(
+      findDrawingPointerShortcut(
+        createEvent({
+          shiftKey: true,
+          altKey: true,
+        }),
+      ),
+    ).toBeUndefined();
+  });
+
+  it('не должен находить shortcut без sourceEvent', () => {
+    expect(findDrawingPointerShortcut({} as MouseEventParams)).toBeUndefined();
+  });
+});
diff --git a/src/core/Drawings/__tests__/utils.test.ts b/src/core/Drawings/__tests__/utils.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..effd68785ecad61b43eb829a31abf8fd0e59e6b6
--- /dev/null
+++ b/src/core/Drawings/__tests__/utils.test.ts
@@ -0,0 +1,54 @@
+import { getOrderedSideValue, updateViews } from '@core/Drawings/utils';
+
+describe('Drawing utils', () => {
+  describe('updateViews', () => {
+    it('должен обновлять все views', () => {
+      const first = {
+        update: jest.fn(),
+      };
+
+      const second = {
+        update: jest.fn(),
+      };
+
+      updateViews([first, second]);
+
+      expect(first.update).toHaveBeenCalledTimes(1);
+      expect(second.update).toHaveBeenCalledTimes(1);
+    });
+
+    it('должен корректно работать с пустым массивом', () => {
+      expect(() => updateViews([])).not.toThrow();
+    });
+  });
+
+  describe('getOrderedSideValue', () => {
+    it('должен возвращать null без startValue', () => {
+      expect(getOrderedSideValue(null, 'end', 10, 20, 'start')).toBeNull();
+    });
+
+    it('должен возвращать null без endValue', () => {
+      expect(getOrderedSideValue('start', null, 10, 20, 'end')).toBeNull();
+    });
+
+    it('должен использовать исходный порядок без координат', () => {
+      expect(getOrderedSideValue('A', 'B', null, 20, 'start')).toBe('A');
+      expect(getOrderedSideValue('A', 'B', 10, null, 'end')).toBe('B');
+    });
+
+    it('должен возвращать startValue для левой координаты', () => {
+      expect(getOrderedSideValue('A', 'B', 10, 20, 'start')).toBe('A');
+      expect(getOrderedSideValue('A', 'B', 10, 20, 'end')).toBe('B');
+    });
+
+    it('должен менять значения местами при обратном порядке координат', () => {
+      expect(getOrderedSideValue('A', 'B', 20, 10, 'start')).toBe('B');
+      expect(getOrderedSideValue('A', 'B', 20, 10, 'end')).toBe('A');
+    });
+
+    it('должен считать одинаковые координаты прямым порядком', () => {
+      expect(getOrderedSideValue('A', 'B', 10, 10, 'start')).toBe('A');
+      expect(getOrderedSideValue('A', 'B', 10, 10, 'end')).toBe('B');
+    });
+  });
+});
diff --git a/src/core/Drawings/axis/CustomPriceAxis.ts b/src/core/Drawings/axis/CustomPriceAxis.ts
index a3d932558861573d8f496665dfe456a1ee41b5fd..d13e343b76319566bdf68673660cd5cec62df483 100644
--- a/src/core/Drawings/axis/CustomPriceAxis.ts
+++ b/src/core/Drawings/axis/CustomPriceAxis.ts
@@ -10,6 +10,7 @@ import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
 
 interface CustomPriceAxisPaneViewParams {
   getAxisSegments(): AxisSegment[];
+  zOrder?: PrimitivePaneViewZOrder;
 }
 
 interface CustomPriceAxisViewParams {
@@ -18,31 +19,27 @@ interface CustomPriceAxisViewParams {
 }
 
 export class CustomPriceAxisPaneView implements IPrimitivePaneView {
-  private readonly getAxisSegments: () => AxisSegment[];
+  private readonly rendererInstance: CustomPriceAxisPaneRenderer;
+  private readonly paneZOrder: PrimitivePaneViewZOrder;
 
-  constructor({ getAxisSegments }: CustomPriceAxisPaneViewParams) {
-    this.getAxisSegments = getAxisSegments;
+  constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
+    this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
+    this.paneZOrder = zOrder;
   }
 
   public update(): void {}
 
   public renderer(): IPrimitivePaneRenderer {
-    return new CustomPriceAxisPaneRenderer({
-      getAxisSegments: this.getAxisSegments,
-    });
+    return this.rendererInstance;
   }
 
   public zOrder(): PrimitivePaneViewZOrder {
-    return 'bottom';
+    return this.paneZOrder;
   }
 }
 
 class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
-  private readonly getAxisSegments: () => AxisSegment[];
-
-  constructor({ getAxisSegments }: CustomPriceAxisPaneViewParams) {
-    this.getAxisSegments = getAxisSegments;
-  }
+  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
 
   public draw(target: CanvasRenderingTarget2D): void {
     const axisSegments = this.getAxisSegments();
diff --git a/src/core/Drawings/axis/CustomTimeAxis.ts b/src/core/Drawings/axis/CustomTimeAxis.ts
index b70ec9cd6520e60b09d51cc84d45c012b07b1da7..8cb6ce817cdb15964ac717823f430dc215400738 100644
--- a/src/core/Drawings/axis/CustomTimeAxis.ts
+++ b/src/core/Drawings/axis/CustomTimeAxis.ts
@@ -10,6 +10,7 @@ import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
 
 interface CustomTimeAxisPaneViewParams {
   getAxisSegments(): AxisSegment[];
+  zOrder?: PrimitivePaneViewZOrder;
 }
 
 interface CustomTimeAxisViewParams {
@@ -18,31 +19,27 @@ interface CustomTimeAxisViewParams {
 }
 
 export class CustomTimeAxisPaneView implements IPrimitivePaneView {
-  private readonly getAxisSegments: () => AxisSegment[];
+  private readonly rendererInstance: CustomTimeAxisPaneRenderer;
+  private readonly paneZOrder: PrimitivePaneViewZOrder;
 
-  constructor({ getAxisSegments }: CustomTimeAxisPaneViewParams) {
-    this.getAxisSegments = getAxisSegments;
+  constructor({ getAxisSegments, zOrder = 'bottom' }: CustomTimeAxisPaneViewParams) {
+    this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
+    this.paneZOrder = zOrder;
   }
 
   public update(): void {}
 
   public renderer(): IPrimitivePaneRenderer {
-    return new CustomTimeAxisPaneRenderer({
-      getAxisSegments: this.getAxisSegments,
-    });
+    return this.rendererInstance;
   }
 
   public zOrder(): PrimitivePaneViewZOrder {
-    return 'bottom';
+    return this.paneZOrder;
   }
 }
 
 class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
-  private readonly getAxisSegments: () => AxisSegment[];
-
-  constructor({ getAxisSegments }: CustomTimeAxisPaneViewParams) {
-    this.getAxisSegments = getAxisSegments;
-  }
+  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
 
   public draw(target: CanvasRenderingTarget2D): void {
     const axisSegments = this.getAxisSegments();
diff --git a/src/core/Drawings/axisLine/axisLine.ts b/src/core/Drawings/axisLine/axisLine.ts
index ba1e2969f04a484ecf89ea225d41364ee89c9fb5..11575873900784b788c923c2d4b3c9fc25e88011 100644
--- a/src/core/Drawings/axisLine/axisLine.ts
+++ b/src/core/Drawings/axisLine/axisLine.ts
@@ -1,14 +1,10 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
-
 import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
 import {
   getPriceFromYCoordinate,
   getTimeFromXCoordinate,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
 } from '@core/Drawings/helpers';
-import { AxisSegment } from '@core/Drawings/types';
 import { updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
@@ -27,13 +23,16 @@ import {
   getAxisLineSettingsTabs,
 } from './settings';
 
-import type { AxisLabel, Point } from '@core/Drawings/types';
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
+import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
 
 export type AxisLineDirection = 'vertical' | 'horizontal';
 
 type AxisLineMode = 'idle' | 'ready' | 'dragging';
+type AxisLineHandleKey = 'main';
 
 interface AxisLineParams extends BaseDrawingParams {
   direction: AxisLineDirection;
@@ -50,22 +49,17 @@ interface AxisLineState {
 export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
   direction: AxisLineDirection;
   coordinate: number;
-  handle: Point;
-  showHandle: boolean;
 }
 
-const HANDLE_HIT_TOLERANCE = 8;
 const LINE_HIT_TOLERANCE = 6;
 
-export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISeriesDrawing {
-  private removeSelf?: () => void;
-  private openSettings?: () => void;
+export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
+  private readonly openSettings?: () => void;
 
   protected settings: AxisLineSettings = createDefaultSettings();
-
   protected mode: AxisLineMode = 'idle';
 
-  private direction: AxisLineDirection;
+  private readonly direction: AxisLineDirection;
   private time: Time | null = null;
   private price: number | null = null;
   private dragPointerId: number | null = null;
@@ -87,14 +81,12 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
     container,
     interaction,
     formatObservable,
-    removeSelf,
     openSettings,
     initialEvent,
   }: AxisLineParams) {
     super({ chart, series, container, interaction });
 
     this.direction = direction;
-    this.removeSelf = removeSelf;
     this.openSettings = openSettings;
 
     this.paneView = new AxisLinePaneView(this);
@@ -208,17 +200,32 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
       return null;
     }
 
-    const { width, height } = this.container.getBoundingClientRect();
-
     return {
       direction: this.direction,
       coordinate,
-      handle: this.direction === 'vertical' ? { x: coordinate, y: height / 2 } : { x: width / 2, y: coordinate },
-      showHandle: this.shouldShowHandles(),
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
+    const coordinate = this.getCoordinate();
+
+    if (coordinate === null) {
+      return [];
+    }
+
+    const { width, height } = this.container.getBoundingClientRect();
+
+    return [
+      {
+        id: 'main',
+        x: this.direction === 'vertical' ? coordinate : width / 2,
+        y: this.direction === 'vertical' ? height / 2 : coordinate,
+        shape: 'rounded',
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle') {
       return null;
@@ -231,7 +238,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
       return null;
     }
 
-    if (this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE)) {
+    if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
       return {
         cursorStyle: this.getCursorStyle(),
         externalId: 'axis-line',
@@ -305,8 +312,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
     return [];
   }
 
-  protected getGeometry(): void {
-    console.log('stub');
+  protected getGeometry(): null {
+    return null;
   }
 
   protected handleDoubleClick = (event: MouseEvent): void => {
@@ -320,13 +327,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
       return;
     }
 
-    const rect = this.container.getBoundingClientRect();
-    const point = {
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    };
-
-    const isNearHandle = this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
+    const point = this.getEventPoint(event as PointerEvent);
+    const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
     const isNearLine = this.isPointNearLine(point, data.coordinate);
 
     if (!isNearHandle && !isNearLine) {
@@ -364,7 +366,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
       return;
     }
 
-    const isNearHandle = this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
+    const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
     const isNearLine = this.isPointNearLine(point, data.coordinate);
 
     if (!this.isSelected()) {
@@ -389,6 +391,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
 
     this.mode = 'dragging';
     this.dragPointerId = event.pointerId;
+
     this.hideCrosshair();
     this.render();
   };
@@ -411,9 +414,9 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
     }
 
     this.mode = 'ready';
-    this.resolveReady?.();
-
     this.dragPointerId = null;
+
+    this.resolveReady?.();
     this.showCrosshair();
     this.render();
   };
@@ -421,8 +424,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
   private startDrawing(point: Point): void {
     this.updateLine(point);
     this.mode = 'ready';
-    this.resolveReady?.();
 
+    this.resolveReady?.();
     this.render();
   }
 
diff --git a/src/core/Drawings/axisLine/paneRenderer.ts b/src/core/Drawings/axisLine/paneRenderer.ts
index cbaeb765d311775618c7b0c44ed0b7d7e5b64f71..9ad4db72f84160b86578b04b311564ec59c07660 100644
--- a/src/core/Drawings/axisLine/paneRenderer.ts
+++ b/src/core/Drawings/axisLine/paneRenderer.ts
@@ -1,14 +1,10 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { AxisLine } from './axisLine';
 
 const UI = {
   lineWidth: 2,
-  handleSize: 10,
-  handleBorderWidth: 1,
   textLineHeightMultiplier: 1.2,
   textOffset: 4,
 };
@@ -27,8 +23,6 @@ export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
 
@@ -63,18 +57,6 @@ export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
         verticalPixelRatio,
       });
 
-      if (data.showHandle) {
-        drawHandle(
-          context,
-          data.handle.x * horizontalPixelRatio,
-          data.handle.y * verticalPixelRatio,
-          horizontalPixelRatio,
-          verticalPixelRatio,
-          colors.chartLineColor,
-          colors.chartBackground,
-        );
-      }
-
       context.restore();
     });
   }
@@ -145,30 +127,3 @@ function drawTextAlongAxisLine(
 
   context.restore();
 }
-
-function drawHandle(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  horizontalPixelRatio: number,
-  verticalPixelRatio: number,
-  strokeColor: string,
-  fillColor: string,
-): void {
-  const width = UI.handleSize * horizontalPixelRatio;
-  const height = UI.handleSize * verticalPixelRatio;
-  const left = x - width / 2;
-  const top = y - height / 2;
-
-  context.save();
-  context.fillStyle = fillColor;
-  context.strokeStyle = strokeColor;
-  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
-  context.beginPath();
-  context.rect(left, top, width, height);
-  context.fill();
-  context.stroke();
-
-  context.restore();
-}
diff --git a/src/core/Drawings/diapson/diapson.ts b/src/core/Drawings/diapson/diapson.ts
index 2d703cb91f6ea78d9e35aff3860a13abe7b45c4c..d51ed2a2faa00c0d99b6e2623b921ac1abbd295a 100644
--- a/src/core/Drawings/diapson/diapson.ts
+++ b/src/core/Drawings/diapson/diapson.ts
@@ -12,7 +12,6 @@ import {
   getContainerSize as getElementContainerSize,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   isPointInBounds,
 } from '@core/Drawings/helpers';
 import { updateViews } from '@core/Drawings/utils';
@@ -34,6 +33,7 @@ import {
   getDiapsonSettingsTabs,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
@@ -43,6 +43,7 @@ export type DiapsonRangeMode = 'date' | 'price';
 
 type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
 type DiapsonHandle = 'body' | 'start' | 'end' | null;
+type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
 type TimeLabelKind = 'left' | 'right';
 type PriceLabelKind = 'top' | 'bottom';
 
@@ -77,7 +78,6 @@ interface DiapsonGeometry {
 export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
   rangeMode: DiapsonRangeMode;
   showFill: boolean;
-  showHandles: boolean;
   labelLines: string[];
 }
 
@@ -93,12 +93,11 @@ interface PriceMetrics {
   steps: number;
 }
 
-const HANDLE_HIT_TOLERANCE = 8;
 const BODY_HIT_TOLERANCE = 6;
 const MIN_RECTANGLE_WIDTH = 6;
 const MIN_RECTANGLE_HEIGHT = 6;
 
-export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeriesDrawing {
+export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
@@ -309,12 +308,36 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
       ...geometry,
       rangeMode: this.rangeMode,
       showFill: true,
-      showHandles: this.shouldShowHandles(),
       labelLines: this.getLabelLines(),
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'end',
+        ...geometry.endPoint,
+        shape: 'circle',
+        size: 12,
+        borderWidth: 2,
+      },
+      {
+        id: 'start',
+        ...geometry.startPoint,
+        shape: 'circle',
+        size: 12,
+        borderWidth: 2,
+      },
+    ];
+  }
+
   protected getTimeAxisSegments(): AxisSegment[] {
     if (!this.isSelected() && !this.isCreationPending()) {
       return [];
@@ -424,7 +447,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
       };
     }
 
-    const handleTarget = this.getHandleTarget(point);
+    const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
 
     if (handleTarget) {
       return {
@@ -452,7 +475,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
 
     const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.getHandleTarget(point) && !this.containsPoint(point)) {
+    if (!this.getDrawingHandleAtPoint(point) && !this.containsPoint(point)) {
       return;
     }
 
@@ -638,17 +661,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
   }
 
   private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
-    const handleTarget = this.getHandleTarget(point);
-
-    if (handleTarget) {
-      return handleTarget;
-    }
-
-    if (this.containsPoint(point)) {
-      return 'body';
-    }
-
-    return null;
+    return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
   }
 
   private moveWhole(point: Point): void {
@@ -1061,24 +1074,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
     return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
   }
 
-  private getHandleTarget(point: Point): Exclude<DiapsonHandle, 'body' | null> | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
-      return 'start';
-    }
-
-    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
-      return 'end';
-    }
-
-    return null;
-  }
-
   private containsPoint(point: Point): boolean {
     const geometry = this.getGeometry();
 
diff --git a/src/core/Drawings/diapson/paneRenderer.ts b/src/core/Drawings/diapson/paneRenderer.ts
index 2246f5b293972e63334513d17fa7e49ff95dc048..e6e830eec25153e02ad63f5131ad9364d3cd4f5e 100644
--- a/src/core/Drawings/diapson/paneRenderer.ts
+++ b/src/core/Drawings/diapson/paneRenderer.ts
@@ -1,16 +1,12 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { Diapson, DiapsonRenderData } from './diapson';
 
 import type { DiapsonTextStyle } from './settings';
 
 const UI = {
   lineWidth: 1,
-  handleRadius: 6,
-  handleBorderWidth: 2,
   labelPadding: 4,
   labelRadius: 2,
   labelBottomOffset: 8,
@@ -32,8 +28,6 @@ export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
       const left = data.left * horizontalPixelRatio;
       const right = data.right * horizontalPixelRatio;
@@ -81,28 +75,6 @@ export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
         );
       }
 
-      if (data.showHandles) {
-        drawHandle(
-          context,
-          data.startPoint.x * horizontalPixelRatio,
-          data.startPoint.y * verticalPixelRatio,
-          horizontalPixelRatio,
-          verticalPixelRatio,
-          colors.chartBackground,
-          colors.chartLineColor,
-        );
-
-        drawHandle(
-          context,
-          data.endPoint.x * horizontalPixelRatio,
-          data.endPoint.y * verticalPixelRatio,
-          horizontalPixelRatio,
-          verticalPixelRatio,
-          colors.chartBackground,
-          colors.chartLineColor,
-        );
-      }
-
       if (data.labelLines.length > 0) {
         drawLabel(context, data, horizontalPixelRatio, verticalPixelRatio, data);
       }
@@ -190,32 +162,6 @@ function drawVerticalArrow(
   context.stroke();
 }
 
-function drawHandle(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  horizontalPixelRatio: number,
-  verticalPixelRatio: number,
-  fillStyle: string,
-  strokeStyle: string,
-): void {
-  const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
-  const radius = UI.handleRadius * pixelRatio;
-  const lineWidth = UI.handleBorderWidth * pixelRatio;
-
-  context.save();
-  context.fillStyle = fillStyle;
-  context.strokeStyle = strokeStyle;
-  context.lineWidth = lineWidth;
-
-  context.beginPath();
-  context.arc(x, y, radius, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-
-  context.restore();
-}
-
 function drawLabel(
   context: CanvasRenderingContext2D,
   data: DiapsonRenderData,
diff --git a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
index 4c5f820a29a9dc7f979876501ccfcd509abc1dd1..49278926a26f4ff43fc812452ed81713d55b96ae 100644
--- a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
+++ b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
@@ -13,7 +13,6 @@ import {
   getPriceDelta as getPriceDeltaFromCoordinates,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   isPointInBounds,
   shiftTimeByPixels,
 } from '@core/Drawings/helpers';
@@ -37,6 +36,7 @@ import {
   mergeFibonacciRetracementSettings,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
@@ -86,7 +86,6 @@ interface FibonacciRetracementGeometry {
   height: number;
   levels: FibonacciRetracementLevelRenderData[];
   areas: FibonacciRetracementAreaRenderData[];
-  handles: Record<FibonacciRetracementHandleKey, Point>;
 }
 
 type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
@@ -94,22 +93,22 @@ type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'le
 export interface FibonacciRetracementRenderData
   extends FibonacciRetracementGeometry,
     FibonacciRetracementRenderSettings {
-  showHandles: boolean;
   backgroundOpacity: number;
 }
 
-const HANDLE_HIT_TOLERANCE = 8;
 const BODY_HIT_TOLERANCE = 6;
 const LINE_HIT_TOLERANCE = 6;
 const MIN_DISTANCE = 6;
 const PERCENT_DIVIDER = 100;
 
-export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracementSettings> implements ISeriesDrawing {
+export class FibonacciRetracement
+  extends SeriesDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
+  implements ISeriesDrawing
+{
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
   protected settings: FibonacciRetracementSettings = createDefaultSettings();
-
   protected mode: FibonacciRetracementMode = 'idle';
 
   private startTime: Time | null = null;
@@ -291,12 +290,9 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
 
     return {
       ...geometry,
-      showHandles: this.shouldShowHandles(),
-
       showBackground: this.settings.showBackground,
       backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
       reverse: this.settings.reverse,
-
       labelsPosition: this.settings.labelsPosition,
       showPrices: this.settings.showPrices,
       showLevelValues: this.settings.showLevelValues,
@@ -306,6 +302,25 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'end',
+        ...geometry.endPoint,
+      },
+      {
+        id: 'start',
+        ...geometry.startPoint,
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
       return null;
@@ -317,9 +332,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
       return null;
     }
 
-    const handleTarget = this.isSelected() ? this.getHandleTarget(point) : null;
-
-    if (handleTarget) {
+    if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
       return {
         cursorStyle: 'pointer',
         externalId: 'fibonacci-retracement-position',
@@ -419,9 +432,13 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
   }
 
   protected handleDoubleClick = (event: MouseEvent): void => {
-    const point = this.getMousePoint(event);
+    if (this.hidden || this.mode !== 'ready') {
+      return;
+    }
+
+    const point = this.getEventPoint(event as PointerEvent);
 
-    if (this.hidden || this.mode !== 'ready' || (!this.containsPoint(point) && !this.getHandleTarget(point))) {
+    if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
       return;
     }
 
@@ -536,7 +553,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
   }
 
   private updateDrawing(point: Point): void {
-    const anchor = this.createAnchor(this.clampPointToContainer(point));
+    const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
 
     if (!anchor) {
       return;
@@ -605,7 +622,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
   }
 
   private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
-    return this.getHandleTarget(point) ?? (this.containsPoint(point) ? 'body' : null);
+    return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
   }
 
   private moveWhole(point: Point): void {
@@ -625,7 +642,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
       return;
     }
 
-    const containerSize = this.getContainerSize();
+    const containerSize = getElementContainerSize(this.container);
 
     const rawOffsetX = point.x - this.dragStartPoint.x;
     const rawOffsetY = point.y - this.dragStartPoint.y;
@@ -638,14 +655,18 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
     const maxOffsetY = containerSize.height - geometry.bottom;
     const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
 
-    const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
-    const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
+    const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startTime, clampedOffsetX, this.series);
+    const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endTime, clampedOffsetX, this.series);
 
     if (nextStartTime === null || nextEndTime === null) {
       return;
     }
 
-    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
+    const priceOffset = getPriceDeltaFromCoordinates(
+      this.series,
+      this.dragStartPoint.y,
+      this.dragStartPoint.y + clampedOffsetY,
+    );
 
     this.startTime = nextStartTime;
     this.endTime = nextEndTime;
@@ -658,7 +679,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
       return;
     }
 
-    const anchor = this.createAnchor(this.clampPointToContainer(point));
+    const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
 
     if (!anchor) {
       return;
@@ -720,10 +741,6 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
       height: bottom - top,
       levels,
       areas: this.getAreas(levels),
-      handles: {
-        start: startPoint,
-        end: endPoint,
-      },
     };
   }
 
@@ -866,24 +883,6 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
       : Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
   }
 
-  private getHandleTarget(point: Point): FibonacciRetracementHandleKey | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.handles.start.x, geometry.handles.start.y, HANDLE_HIT_TOLERANCE)) {
-      return 'start';
-    }
-
-    if (isNearPoint(point, geometry.handles.end.x, geometry.handles.end.y, HANDLE_HIT_TOLERANCE)) {
-      return 'end';
-    }
-
-    return null;
-  }
-
   private containsPoint(point: Point): boolean {
     const geometry = this.getGeometry();
 
@@ -906,29 +905,4 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
 
     return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
   }
-
-  private shiftTime(time: Time, offsetX: number): Time | null {
-    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
-  }
-
-  private getPriceDelta(fromY: number, toY: number): number {
-    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
-  }
-
-  private getContainerSize(): { width: number; height: number } {
-    return getElementContainerSize(this.container);
-  }
-
-  private clampPointToContainer(point: Point): Point {
-    return clampPointToContainerInElement(point, this.container);
-  }
-
-  private getMousePoint(event: MouseEvent): Point {
-    const rect = this.container.getBoundingClientRect();
-
-    return this.clampPointToContainer({
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    });
-  }
 }
diff --git a/src/core/Drawings/fibonacciRetracement/paneRenderer.ts b/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
index 11fbf6d0be30fdaa506957a328312c36e5ac47f8..c656f03b2103cc26387384e33cd26edc9e0f86b2 100644
--- a/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
+++ b/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
@@ -7,8 +7,6 @@ import { FibonacciRetracement } from './fibonacciRetracement';
 
 const UI = {
   lineWidth: 1,
-  handleSize: 10,
-  handleBorderWidth: 1,
   textOffset: 4,
   controlLineWidth: 1,
 };
@@ -87,28 +85,6 @@ export class FibonacciRetracementPaneRenderer implements IPrimitivePaneRenderer
         pixelRatio,
       });
 
-      if (data.showHandles) {
-        drawHandle(
-          context,
-          data.startPoint.x * horizontalPixelRatio,
-          data.startPoint.y * verticalPixelRatio,
-          horizontalPixelRatio,
-          verticalPixelRatio,
-          colors.chartLineColor,
-          colors.chartBackground,
-        );
-
-        drawHandle(
-          context,
-          data.endPoint.x * horizontalPixelRatio,
-          data.endPoint.y * verticalPixelRatio,
-          horizontalPixelRatio,
-          verticalPixelRatio,
-          colors.chartLineColor,
-          colors.chartBackground,
-        );
-      }
-
       context.restore();
     });
   }
@@ -221,31 +197,3 @@ function drawText(
 
   context.restore();
 }
-
-function drawHandle(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  horizontalPixelRatio: number,
-  verticalPixelRatio: number,
-  strokeColor: string,
-  fillColor: string,
-): void {
-  const width = UI.handleSize * horizontalPixelRatio;
-  const height = UI.handleSize * verticalPixelRatio;
-  const left = x - width / 2;
-  const top = y - height / 2;
-
-  context.save();
-
-  context.fillStyle = fillColor;
-  context.strokeStyle = strokeColor;
-  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
-  context.beginPath();
-  context.rect(left, top, width, height);
-  context.fill();
-  context.stroke();
-
-  context.restore();
-}
diff --git a/src/core/Drawings/handles.ts b/src/core/Drawings/handles.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3eaea25fb80f4c6f02cb564aac81a4ee7ffed85f
--- /dev/null
+++ b/src/core/Drawings/handles.ts
@@ -0,0 +1,225 @@
+import { isNearPoint } from '@core/Drawings/helpers';
+import { drawRoundedRect } from '@core/Drawings/utils';
+import { getThemeStore } from '@src/theme';
+
+import type { Point } from '@core/Drawings/types';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type {
+  IPrimitivePaneRenderer,
+  IPrimitivePaneView,
+  ISeriesPrimitive,
+  SeriesAttachedParameter,
+  Time,
+} from 'lightweight-charts';
+
+export type DrawingHandleShape = 'circle' | 'rounded';
+
+export interface DrawingHandle<TId extends string = string> {
+  id: TId;
+  x: number;
+  y: number;
+  shape?: DrawingHandleShape;
+  size?: number;
+  borderWidth?: number;
+  strokeColor?: string;
+}
+
+const DEFAULT_HANDLE_SIZE = 12;
+const DEFAULT_HANDLE_BORDER_WIDTH = 2;
+const HANDLE_HIT_TOLERANCE = 8;
+const HANDLE_RADIUS = 2;
+
+export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
+  private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
+  private series: SeriesAttachedParameter<Time>['series'] | null = null;
+
+  private readonly paneRenderer: IPrimitivePaneRenderer = {
+    draw: (target) => this.drawPane(target),
+  };
+
+  private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
+    draw: (target) => this.drawTimeAxis(target),
+  };
+
+  private readonly paneViewsList: readonly IPrimitivePaneView[] = [
+    {
+      renderer: () => this.paneRenderer,
+      zOrder: () => 'top',
+    },
+  ];
+
+  private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
+    {
+      renderer: () => this.timeAxisRenderer,
+      zOrder: () => 'top',
+    },
+  ];
+
+  constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}
+
+  public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
+    this.chart = chart;
+    this.series = series;
+  }
+
+  public detached(): void {
+    this.chart = null;
+    this.series = null;
+  }
+
+  public paneViews(): readonly IPrimitivePaneView[] {
+    return this.paneViewsList;
+  }
+
+  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
+    return this.timeAxisPaneViewsList;
+  }
+
+  public findHandle(point: Point): DrawingHandle<TId> | null {
+    const handles = this.getHandles();
+
+    for (let index = handles.length - 1; index >= 0; index -= 1) {
+      const handle = handles[index];
+
+      if (isNearPoint(point, handle.x, handle.y, getHitTolerance(handle))) {
+        return handle;
+      }
+    }
+
+    return null;
+  }
+
+  public isTimeAxisHit(point: Point): boolean {
+    if (!this.isTimeAxisAdjacent()) {
+      return false;
+    }
+
+    const paneHeight = this.getPaneHeight();
+
+    if (paneHeight <= 0 || point.y < paneHeight) {
+      return false;
+    }
+
+    const handle = this.findHandle(point);
+
+    return handle !== null && intersectsTimeAxis(handle, paneHeight);
+  }
+
+  private drawPane(target: CanvasRenderingTarget2D): void {
+    const handles = this.getHandles();
+
+    if (!handles.length) {
+      return;
+    }
+
+    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
+      for (const handle of handles) {
+        drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
+      }
+    });
+  }
+
+  private drawTimeAxis(target: CanvasRenderingTarget2D): void {
+    if (!this.isTimeAxisAdjacent()) {
+      return;
+    }
+
+    const paneHeight = this.getPaneHeight();
+
+    if (paneHeight <= 0) {
+      return;
+    }
+
+    const handles = this.getHandles();
+
+    if (!handles.length) {
+      return;
+    }
+
+    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
+      const axisHeight = bitmapSize.height / verticalPixelRatio;
+
+      for (const handle of handles) {
+        if (!intersectsTimeAxis(handle, paneHeight)) {
+          continue;
+        }
+
+        const axisY = handle.y - paneHeight;
+        const halfSize = getHandleSize(handle) / 2;
+
+        if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
+          continue;
+        }
+
+        drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
+      }
+    });
+  }
+
+  private getPaneHeight(): number {
+    return this.series?.getPane().getHeight() ?? 0;
+  }
+
+  private isTimeAxisAdjacent(): boolean {
+    if (!this.chart || !this.series) {
+      return false;
+    }
+
+    return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
+  }
+}
+
+function getHandleSize(handle: DrawingHandle): number {
+  return handle.size ?? DEFAULT_HANDLE_SIZE;
+}
+
+function getHitTolerance(handle: DrawingHandle): number {
+  return Math.max(HANDLE_HIT_TOLERANCE, getHandleSize(handle) / 2);
+}
+
+function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
+  const halfSize = getHandleSize(handle) / 2;
+
+  return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
+}
+
+function drawHandle(
+  context: CanvasRenderingContext2D,
+  handle: DrawingHandle,
+  horizontalPixelRatio: number,
+  verticalPixelRatio: number,
+): void {
+  const size = getHandleSize(handle);
+  const shape = handle.shape ?? 'circle';
+  const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
+  const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
+  const width = size * horizontalPixelRatio;
+  const height = size * verticalPixelRatio;
+  const x = handle.x * horizontalPixelRatio;
+  const y = handle.y * verticalPixelRatio;
+  const inset = lineWidth / 2;
+  const { colors } = getThemeStore();
+
+  context.save();
+  context.fillStyle = colors.chartBackground;
+  context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
+  context.lineWidth = lineWidth;
+  context.beginPath();
+
+  if (shape === 'circle') {
+    const radius = Math.max(Math.min(width, height) / 2 - inset, 0);
+
+    context.arc(x, y, radius, 0, Math.PI * 2);
+  } else {
+    const left = x - width / 2 + inset;
+    const top = y - height / 2 + inset;
+    const drawWidth = Math.max(width - lineWidth, 0);
+    const drawHeight = Math.max(height - lineWidth, 0);
+
+    drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
+  }
+
+  context.fill();
+  context.stroke();
+  context.restore();
+}
diff --git a/src/core/Drawings/helpers.ts b/src/core/Drawings/helpers.ts
index 8ba05801913c2b90080912f718892932fe4330f1..c536f3f4f17c35fe264b456810a666e930b6a346 100644
--- a/src/core/Drawings/helpers.ts
+++ b/src/core/Drawings/helpers.ts
@@ -69,16 +69,17 @@ export function clampPointToContainer(point: Point, container: HTMLElement): Poi
   };
 }
 
-export function getPointerPoint(container: HTMLElement, event: PointerEvent | TouchMouseEventData): Point {
+export function getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
   const rect = container.getBoundingClientRect();
 
-  return clampPointToContainer(
-    {
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    },
-    container,
-  );
+  return {
+    x: event.clientX - rect.left,
+    y: event.clientY - rect.top,
+  };
+}
+
+export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
+  return clampPointToContainer(getRawPointerPoint(container, event), container);
 }
 
 export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
diff --git a/src/core/Drawings/line/lineDrawing.ts b/src/core/Drawings/line/lineDrawing.ts
index 52331ea34f722940589d66cd2553c468cc76d2ea..be4521a4d8ce476cdd8ce0dea586025a7997d55c 100644
--- a/src/core/Drawings/line/lineDrawing.ts
+++ b/src/core/Drawings/line/lineDrawing.ts
@@ -11,10 +11,9 @@ import {
   getPriceDelta as getPriceDeltaFromCoordinates,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   shiftTimeByPixels,
 } from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
 import { getThemeStore } from '@src/theme';
@@ -33,10 +32,12 @@ import {
   LineDrawingTextStyle,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 
 type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
+type LineDrawingHandleKey = 'start' | 'end';
 type TimeLabelKind = 'start' | 'end';
 type PriceLabelKind = 'start' | 'end';
 
@@ -61,14 +62,15 @@ interface LineDrawingGeometry {
   bottom: number;
 }
 
-export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {
-  showHandles: boolean;
-}
+export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
 
 const LINE_HIT_TOLERANCE = 6;
 const MIN_LINE_SIZE = 4;
 
-export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implements ISeriesDrawing {
+export class LineDrawing
+  extends SeriesDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
+  implements ISeriesDrawing
+{
   private removeSelf?: () => void;
   private openSettings?: () => void;
   private readonly defaultMarkers: LineDrawingMarkers;
@@ -264,11 +266,33 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
 
     return {
       ...geometry,
-      showHandles: this.shouldShowHandles(),
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'start',
+        ...geometry.startPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'end',
+        ...geometry.endPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
       return null;
@@ -276,7 +300,7 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
 
     const point = { x, y };
 
-    if (this.getPointTarget(point)) {
+    if (this.getDrawingHandleAtPoint(point)) {
       return {
         cursorStyle: 'move',
         externalId: 'line-drawing',
@@ -388,13 +412,9 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
       return;
     }
 
-    const rect = this.container.getBoundingClientRect();
-    const point = {
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    };
+    const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
+    if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point)) {
       return;
     }
 
@@ -411,20 +431,17 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
 
     const point = this.getEventPoint(event);
 
-    if (this.mode === 'idle') {
+    if (this.mode === 'idle' || this.mode === 'drawing') {
       event.preventDefault();
       event.stopPropagation();
 
-      this.startDrawing(point);
-      return;
-    }
-
-    if (this.mode === 'drawing') {
-      event.preventDefault();
-      event.stopPropagation();
+      if (this.mode === 'idle') {
+        this.startDrawing(point);
+      } else {
+        this.updateDrawing(point);
+        this.finishDrawing();
+      }
 
-      this.updateDrawing(point);
-      this.finishDrawing();
       return;
     }
 
@@ -432,47 +449,29 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
       return;
     }
 
-    const pointTarget = this.getPointTarget(point);
+    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
     const isNearLine = this.isPointNearLine(point);
     const isDrawingHit = pointTarget !== null || isNearLine;
+    const isSelected = this.isSelected();
 
-    if (!this.isSelected()) {
-      if (!isDrawingHit) {
-        return;
+    if (!isDrawingHit) {
+      if (isSelected) {
+        this.deselect();
       }
 
-      event.preventDefault();
-      event.stopPropagation();
-
-      this.select();
       return;
     }
 
-    if (pointTarget === 'start') {
-      event.preventDefault();
-      event.stopPropagation();
-
-      this.startDragging('dragging-start', point, event.pointerId);
-      return;
-    }
-
-    if (pointTarget === 'end') {
-      event.preventDefault();
-      event.stopPropagation();
-
-      this.startDragging('dragging-end', point, event.pointerId);
-      return;
-    }
-
-    if (isNearLine) {
-      event.preventDefault();
-      event.stopPropagation();
+    event.preventDefault();
+    event.stopPropagation();
 
-      this.startDragging('dragging-body', point, event.pointerId);
+    if (!isSelected) {
+      this.select();
       return;
     }
 
-    this.deselect();
+    const dragMode: LineDrawingMode = `dragging-${pointTarget ?? 'body'}`;
+    this.startDragging(dragMode, point, event.pointerId);
   };
 
   protected handlePointerMove = (event: PointerEvent): void => {
@@ -565,7 +564,6 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
 
   private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
     this.mode = mode;
-
     this.dragPointerId = pointerId;
     this.dragStartPoint = point;
     this.dragStateSnapshot = this.getState();
@@ -668,24 +666,6 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
     };
   }
 
-  private getPointTarget(point: Point): 'start' | 'end' | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
-      return 'start';
-    }
-
-    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
-      return 'end';
-    }
-
-    return null;
-  }
-
   private isPointNearLine(point: Point): boolean {
     const geometry = this.getGeometry();
 
@@ -693,26 +673,7 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
       return false;
     }
 
-    return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
-  }
-
-  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
-    const dx = endPoint.x - startPoint.x;
-    const dy = endPoint.y - startPoint.y;
-
-    if (dx === 0 && dy === 0) {
-      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
-    }
-
-    const t = Math.max(
-      0,
-      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
-    );
-
-    const projectionX = startPoint.x + t * dx;
-    const projectionY = startPoint.y + t * dy;
-
-    return Math.hypot(point.x - projectionX, point.y - projectionY);
+    return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
   }
 
   private getTimeCoordinate(kind: TimeLabelKind): number | null {
diff --git a/src/core/Drawings/line/paneRenderer.ts b/src/core/Drawings/line/paneRenderer.ts
index 232d5b0fc70378a9d8d2ce77ca7908acbf099e0b..a7b709023f2b18ab077e802d92edfd7ab471881e 100644
--- a/src/core/Drawings/line/paneRenderer.ts
+++ b/src/core/Drawings/line/paneRenderer.ts
@@ -1,16 +1,12 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { LineMarker } from '@src/types';
 
 import { LineDrawing } from './lineDrawing';
 
 const UI = {
   lineWidth: 2,
-  handleRadius: 5,
-  handleBorderWidth: 2,
   arrowLength: 12,
   arrowAngle: Math.PI / 6,
   textLineHeightMultiplier: 1.2,
@@ -31,8 +27,6 @@ export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
 
@@ -87,15 +81,6 @@ export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
         });
       }
 
-      if (data.showHandles) {
-        context.fillStyle = colors.chartBackground;
-        context.strokeStyle = colors.chartLineColor;
-        context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
-        drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
-        drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
-      }
-
       context.restore();
     });
   }
@@ -132,13 +117,6 @@ function drawArrowHead(
   context.stroke();
 }
 
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
-  context.beginPath();
-  context.arc(x, y, radius, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-}
-
 function drawTextAlongLine(
   context: CanvasRenderingContext2D,
   params: {
diff --git a/src/core/Drawings/parallelChannel/paneRenderer.ts b/src/core/Drawings/parallelChannel/paneRenderer.ts
index f394adfea83a5c53d34d30844dcaed298e402211..634b6fc57f369ecc6a0eef142e5f859654dac73e 100644
--- a/src/core/Drawings/parallelChannel/paneRenderer.ts
+++ b/src/core/Drawings/parallelChannel/paneRenderer.ts
@@ -1,8 +1,6 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import type { ParallelChannel } from './parallelChannel';
 
 import type { Point } from '@core/Drawings/types';
@@ -12,10 +10,9 @@ const UI = {
   middleLineWidth: 1,
   middleLineDash: 6,
   middleLineGap: 4,
-  handleRadius: 5,
-  handleBorderWidth: 2,
   textLineHeightMultiplier: 1.2,
   textOffset: 5,
+  textStartPadding: 15,
   textStartGap: 5,
 };
 
@@ -86,25 +83,6 @@ export class ParallelChannelPaneRenderer implements IPrimitivePaneRenderer {
         });
       }
 
-      if (data.showHandles) {
-        const { colors } = getThemeStore();
-
-        context.fillStyle = colors.chartBackground;
-
-        context.strokeStyle = colors.chartLineColor;
-
-        context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
-        const radius = UI.handleRadius * pixelRatio;
-
-        drawHandle(context, startPoint, radius);
-        drawHandle(context, mainMiddlePoint, radius);
-        drawHandle(context, endPoint, radius);
-        drawHandle(context, parallelStartPoint, radius);
-        drawHandle(context, parallelMiddlePoint, radius);
-        drawHandle(context, parallelEndPoint, radius);
-      }
-
       context.restore();
     });
   }
@@ -141,13 +119,6 @@ function drawLine(context: CanvasRenderingContext2D, startPoint: Point, endPoint
   context.stroke();
 }
 
-function drawHandle(context: CanvasRenderingContext2D, point: Point, radius: number): void {
-  context.beginPath();
-  context.arc(point.x, point.y, radius, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-}
-
 function drawTextAlongLine(
   context: CanvasRenderingContext2D,
   params: {
@@ -199,19 +170,12 @@ function drawTextAlongLine(
   }
 
   const directionX = deltaX / lineLength;
-
   const directionY = deltaY / lineLength;
-
   const lineMiddlePoint = getMiddlePoint(startPoint, endPoint);
-
   const outwardX = lineMiddlePoint.x - channelCenterPoint.x;
-
   const outwardY = lineMiddlePoint.y - channelCenterPoint.y;
-
   const outwardLength = Math.hypot(outwardX, outwardY);
-
   const normalX = outwardLength ? outwardX / outwardLength : 0;
-
   const normalY = outwardLength ? outwardY / outwardLength : -1;
 
   const lines = text.split('\n');
@@ -222,7 +186,6 @@ function drawTextAlongLine(
   const fontStyle = isItalic ? 'italic ' : '';
 
   context.save();
-
   context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
 
   const textWidth = lines.reduce((maxWidth, line) => {
@@ -230,12 +193,9 @@ function drawTextAlongLine(
   }, 0);
 
   const blockHeight = lines.length * lineHeight;
-
-  const startPadding = (UI.handleRadius * 2 + UI.textStartGap) * pixelRatio;
-
+  const startPadding = UI.textStartPadding * pixelRatio;
   const desiredDistance = startPadding + textWidth / 2;
   const availableDistance = lineLength - textWidth / 2 - UI.textStartGap * pixelRatio;
-
   const distanceAlongLine = availableDistance >= desiredDistance ? desiredDistance : lineLength / 2;
   const outwardDistance = blockHeight / 2 + UI.textOffset * verticalPixelRatio;
 
diff --git a/src/core/Drawings/parallelChannel/parallelChannel.ts b/src/core/Drawings/parallelChannel/parallelChannel.ts
index ba1846ab0b625d6533bfb236c279a24934dd8417..13b1881a5201fca98612933e627be2dc90ea60dd 100644
--- a/src/core/Drawings/parallelChannel/parallelChannel.ts
+++ b/src/core/Drawings/parallelChannel/parallelChannel.ts
@@ -1,11 +1,4 @@
-import {
-  IChartApi,
-  IPrimitivePaneView,
-  PrimitiveHoveredItem,
-  TouchMouseEventData,
-  UTCTimestamp,
-} from 'lightweight-charts';
-import { Observable } from 'rxjs';
+import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
 
 import {
   CustomPriceAxisPaneView,
@@ -19,10 +12,9 @@ import {
   getPriceFromYCoordinate,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   shiftTimeByPixels,
 } from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
 import { getThemeStore } from '@src/theme';
@@ -39,6 +31,7 @@ import {
   ParallelChannelTextStyle,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
@@ -54,8 +47,8 @@ type ParallelChannelDragTarget =
   | 'parallel-end'
   | 'body';
 
+type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
 type TimeLabelKind = 'start' | 'end';
-
 type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';
 
 type ParallelChannelParams = BaseDrawingParams;
@@ -87,17 +80,17 @@ interface ParallelChannelGeometry {
 export interface ParallelChannelRenderData
   extends ParallelChannelGeometry,
     ParallelChannelStyle,
-    ParallelChannelTextStyle {
-  showHandles: boolean;
-}
+    ParallelChannelTextStyle {}
 
-const HANDLE_HIT_TOLERANCE = 8;
 const LINE_HIT_TOLERANCE = 6;
 const MIN_LINE_SIZE = 4;
 const MIN_CHANNEL_WIDTH = 4;
 const VERTICAL_LINE_TOLERANCE = 0.001;
 
-export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings> implements ISeriesDrawing {
+export class ParallelChannel
+  extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
+  implements ISeriesDrawing
+{
   private openSettings?: () => void;
 
   protected settings: ParallelChannelSettings = createDefaultSettings();
@@ -314,17 +307,63 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     return {
       ...geometry,
       ...this.settings,
-      showHandles: this.shouldShowHandles(),
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<ParallelChannelHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'parallel-end',
+        ...geometry.parallelEndPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'parallel-middle',
+        ...geometry.parallelMiddlePoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'parallel-start',
+        ...geometry.parallelStartPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-end',
+        ...geometry.endPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-middle',
+        ...geometry.mainMiddlePoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'main-start',
+        ...geometry.startPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode !== 'ready') {
       return null;
     }
 
     const point = { x, y };
-    const pointTarget = this.getPointTarget(point);
+    const pointTarget = this.getDrawingHandleAtPoint(point);
     const isChannelHit = this.isPointOnChannel(point);
 
     if (!pointTarget && !isChannelHit) {
@@ -456,7 +495,7 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
 
     const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.isPointOnChannel(point) && !this.getPointTarget(point)) {
+    if (!this.isPointOnChannel(point) && !this.getDrawingHandleAtPoint(point)) {
       return;
     }
 
@@ -473,82 +512,67 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
 
     const point = this.getEventPoint(event);
 
-    if (this.mode === 'idle') {
-      event.preventDefault();
-      event.stopPropagation();
-
-      this.startDrawing(point);
-      return;
-    }
+    switch (this.mode) {
+      case 'idle':
+        event.preventDefault();
+        event.stopPropagation();
+        this.startDrawing(point);
+        return;
 
-    if (this.mode === 'drawing-line') {
-      event.preventDefault();
-      event.stopPropagation();
+      case 'drawing-line':
+        event.preventDefault();
+        event.stopPropagation();
+        this.setEndAnchor(point);
 
-      this.setEndAnchor(point);
+        if (!this.hasValidMainLine()) {
+          this.render();
+          return;
+        }
 
-      if (!this.hasValidMainLine()) {
+        this.priceOffset = 0;
+        this.mode = 'drawing-channel';
         this.render();
         return;
-      }
-
-      this.priceOffset = 0;
-      this.mode = 'drawing-channel';
-
-      this.render();
-      return;
-    }
 
-    if (this.mode === 'drawing-channel') {
-      event.preventDefault();
-      event.stopPropagation();
+      case 'drawing-channel':
+        event.preventDefault();
+        event.stopPropagation();
+        this.setPriceOffset(point);
 
-      this.setPriceOffset(point);
+        if (!this.hasValidChannelWidth()) {
+          this.render();
+          return;
+        }
 
-      if (!this.hasValidChannelWidth()) {
-        this.render();
+        this.finishDrawing();
         return;
-      }
 
-      this.finishDrawing();
-      return;
-    }
+      case 'ready':
+        break;
 
-    if (this.mode !== 'ready') {
-      return;
+      default:
+        return;
     }
 
-    const pointTarget = this.getPointTarget(point);
+    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
     const isChannelHit = this.isPointOnChannel(point);
-    const isDrawingHit = pointTarget !== null || isChannelHit;
-
-    if (!this.isSelected()) {
-      if (!isDrawingHit) {
-        return;
-      }
-
-      event.preventDefault();
-      event.stopPropagation();
+    const isSelected = this.isSelected();
 
-      this.select();
+    if (!isSelected && !pointTarget && !isChannelHit) {
       return;
     }
 
-    if (pointTarget) {
+    if (!isSelected) {
       event.preventDefault();
       event.stopPropagation();
-
-      this.startDragging(pointTarget, point, event.pointerId);
-
+      this.select();
       return;
     }
 
-    if (isChannelHit) {
+    if (pointTarget || isChannelHit) {
       event.preventDefault();
       event.stopPropagation();
-
-      this.startDragging('body', point, event.pointerId);
-
+      this.startDragging(pointTarget ?? 'body', point, event.pointerId);
       return;
     }
 
@@ -612,13 +636,9 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     }
 
     const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
-
     const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
-
     const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
-
     const middleEndPoint = getMiddlePoint(endPoint, parallelEndPoint);
-
     const points = [startPoint, endPoint, parallelStartPoint, parallelEndPoint];
 
     return {
@@ -783,7 +803,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     }
 
     const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
-
     const linePrice = this.getLinePriceAtX(snapshot.startAnchor, snapshot.endAnchor, point.x);
 
     if (pointerPrice === null || linePrice === null) {
@@ -833,11 +852,9 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     }
 
     const offsetX = point.x - this.dragStartPoint.x;
-
     const priceDelta = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
 
     const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
-
     const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
 
     if (startTime === null || endTime === null) {
@@ -873,7 +890,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     }
 
     const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
-
     const linePrice = this.getLinePriceAtX(this.startAnchor, this.endAnchor, point.x);
 
     if (pointerPrice === null || linePrice === null) {
@@ -898,7 +914,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     }
 
     const ratio = (x - startPoint.x) / deltaX;
-
     const y = startPoint.y + (endPoint.y - startPoint.y) * ratio;
 
     return getPriceFromYCoordinate(this.series, y);
@@ -924,31 +939,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
     return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
   }
 
-  private getPointTarget(point: Point): ParallelChannelDragTarget | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    const targets: [ParallelChannelDragTarget, Point][] = [
-      ['main-start', geometry.startPoint],
-      ['main-middle', geometry.mainMiddlePoint],
-      ['main-end', geometry.endPoint],
-      ['parallel-start', geometry.parallelStartPoint],
-      ['parallel-middle', geometry.parallelMiddlePoint],
-      ['parallel-end', geometry.parallelEndPoint],
-    ];
-
-    for (const [target, targetPoint] of targets) {
-      if (isNearPoint(point, targetPoint.x, targetPoint.y, HANDLE_HIT_TOLERANCE)) {
-        return target;
-      }
-    }
-
-    return null;
-  }
-
   private isPointOnChannel(point: Point): boolean {
     const geometry = this.getGeometry();
 
@@ -1004,7 +994,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
 
   private getAnchorPoint(anchor: Anchor): Point | null {
     const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
-
     const y = getYCoordinateFromPrice(this.series, anchor.price);
 
     if (x === null || y === null) {
@@ -1037,29 +1026,6 @@ function getDistance(startPoint: Point, endPoint: Point): number {
   return Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
 }
 
-function getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
-  const deltaX = endPoint.x - startPoint.x;
-  const deltaY = endPoint.y - startPoint.y;
-
-  if (deltaX === 0 && deltaY === 0) {
-    return getDistance(point, startPoint);
-  }
-
-  const ratio = Math.max(
-    0,
-    Math.min(
-      1,
-      ((point.x - startPoint.x) * deltaX + (point.y - startPoint.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY),
-    ),
-  );
-
-  const projectionX = startPoint.x + ratio * deltaX;
-
-  const projectionY = startPoint.y + ratio * deltaY;
-
-  return Math.hypot(point.x - projectionX, point.y - projectionY);
-}
-
 function isPointInPolygon(point: Point, polygon: Point[]): boolean {
   let isInside = false;
 
diff --git a/src/core/Drawings/ray/paneRenderer.ts b/src/core/Drawings/ray/paneRenderer.ts
index 066800357c77821e8fee847d7d600a7c26ce55d6..5ec31743f324e13370dd3ada16f89be45957464f 100644
--- a/src/core/Drawings/ray/paneRenderer.ts
+++ b/src/core/Drawings/ray/paneRenderer.ts
@@ -1,14 +1,10 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { Ray } from './ray';
 
 const UI = {
   lineWidth: 2,
-  handleRadius: 5,
-  handleBorderWidth: 2,
   textLineHeightMultiplier: 1.2,
   textOffset: 4,
 };
@@ -27,8 +23,6 @@ export class RayPaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
 
@@ -61,15 +55,6 @@ export class RayPaneRenderer implements IPrimitivePaneRenderer {
         pixelRatio,
       });
 
-      if (data.showHandles) {
-        context.fillStyle = colors.chartBackground;
-        context.strokeStyle = data.lineColor;
-        context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
-        drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
-        drawHandle(context, directionX, directionY, UI.handleRadius * pixelRatio);
-      }
-
       context.restore();
     });
   }
@@ -134,10 +119,3 @@ function drawRayText(
 
   context.restore();
 }
-
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
-  context.beginPath();
-  context.arc(x, y, radius, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-}
diff --git a/src/core/Drawings/ray/ray.ts b/src/core/Drawings/ray/ray.ts
index 8f7b24e1e438095466ac369594584aa463a3414f..161f342ea9ef7832377847e3af297839238ed227 100644
--- a/src/core/Drawings/ray/ray.ts
+++ b/src/core/Drawings/ray/ray.ts
@@ -1,4 +1,4 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, TouchMouseEventData, UTCTimestamp } from 'lightweight-charts';
+import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
 
 import {
   CustomPriceAxisPaneView,
@@ -11,10 +11,9 @@ import {
   getPriceDelta as getPriceDeltaFromCoordinates,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   shiftTimeByPixels,
 } from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
 import { getThemeStore } from '@src/theme';
@@ -26,11 +25,13 @@ import { RayPaneView } from './paneView';
 
 import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
 
 type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
+type RayHandleKey = 'start' | 'direction';
 type TimeLabelKind = 'start' | 'direction';
 type PriceLabelKind = 'start' | 'direction';
 
@@ -54,20 +55,16 @@ interface RayGeometry {
   bottom: number;
 }
 
-export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {
-  showHandles: boolean;
-}
+export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}
 
-const POINT_HIT_TOLERANCE = 8;
 const LINE_HIT_TOLERANCE = 6;
 const MIN_LINE_SIZE = 4;
 
-export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawing {
+export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements ISeriesDrawing {
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
   protected settings: RaySettings = createDefaultSettings();
-
   protected mode: RayMode = 'idle';
 
   private startAnchor: Anchor | null = null;
@@ -245,11 +242,35 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
 
     return {
       ...geometry,
-      showHandles: this.shouldShowHandles(),
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'start',
+        ...geometry.startPoint,
+        shape: 'circle',
+        borderWidth: 2,
+        strokeColor: this.settings.lineColor,
+      },
+      {
+        id: 'direction',
+        ...geometry.directionPoint,
+        shape: 'circle',
+        borderWidth: 2,
+        strokeColor: this.settings.lineColor,
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
       return null;
@@ -257,7 +278,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
 
     const point = { x, y };
 
-    if (this.getPointTarget(point)) {
+    if (this.getDrawingHandleAtPoint(point)) {
       return {
         cursorStyle: 'move',
         externalId: 'ray',
@@ -369,13 +390,9 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
       return;
     }
 
-    const rect = this.container.getBoundingClientRect();
-    const point = {
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    };
+    const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.getPointTarget(point) && !this.isPointNearRay(point)) {
+    if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearRay(point)) {
       return;
     }
 
@@ -413,7 +430,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
       return;
     }
 
-    const pointTarget = this.getPointTarget(point);
+    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
     const isNearRay = this.isPointNearRay(point);
     const isDrawingHit = pointTarget !== null || isNearRay;
 
@@ -546,7 +563,6 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
 
   private startDragging(mode: RayMode, point: Point, pointerId: number): void {
     this.mode = mode;
-
     this.dragPointerId = pointerId;
     this.dragStartPoint = point;
     this.dragStateSnapshot = this.getState();
@@ -702,24 +718,6 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
     return candidates[0] ?? directionPoint;
   }
 
-  private getPointTarget(point: Point): 'start' | 'direction' | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, POINT_HIT_TOLERANCE)) {
-      return 'start';
-    }
-
-    if (isNearPoint(point, geometry.directionPoint.x, geometry.directionPoint.y, POINT_HIT_TOLERANCE)) {
-      return 'direction';
-    }
-
-    return null;
-  }
-
   private isPointNearRay(point: Point): boolean {
     const geometry = this.getGeometry();
 
@@ -727,26 +725,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
       return false;
     }
 
-    return this.getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
-  }
-
-  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
-    const dx = endPoint.x - startPoint.x;
-    const dy = endPoint.y - startPoint.y;
-
-    if (dx === 0 && dy === 0) {
-      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
-    }
-
-    const t = Math.max(
-      0,
-      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
-    );
-
-    const projectionX = startPoint.x + t * dx;
-    const projectionY = startPoint.y + t * dy;
-
-    return Math.hypot(point.x - projectionX, point.y - projectionY);
+    return getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
   }
 
   private getTimeCoordinate(kind: TimeLabelKind): number | null {
diff --git a/src/core/Drawings/rectangle/paneRenderer.ts b/src/core/Drawings/rectangle/paneRenderer.ts
index debed3a27c0e0d92ed650e95c2c6f5e4bc2b9684..b996274d5965dc9055e4d10c6700767aa00dc28f 100644
--- a/src/core/Drawings/rectangle/paneRenderer.ts
+++ b/src/core/Drawings/rectangle/paneRenderer.ts
@@ -1,14 +1,10 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { Rectangle } from './rectangle';
 
 const UI = {
   borderWidth: 1,
-  handleSize: 10,
-  handleBorderWidth: 1,
   textOffset: 4,
   textLineHeightMultiplier: 1.2,
 };
@@ -27,11 +23,8 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
-
       const left = data.left * horizontalPixelRatio;
       const right = data.right * horizontalPixelRatio;
       const top = data.top * verticalPixelRatio;
@@ -39,10 +32,8 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
 
       context.save();
 
-      if (data.showFill) {
-        context.fillStyle = data.fillColor;
-        context.fillRect(left, top, right - left, bottom - top);
-      }
+      context.fillStyle = data.fillColor;
+      context.fillRect(left, top, right - left, bottom - top);
 
       context.lineWidth = UI.borderWidth * pixelRatio;
       context.strokeStyle = data.borderColor;
@@ -50,7 +41,6 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
 
       drawRectangleText(context, {
         left,
-        right,
         top,
         text: data.text,
         fontSize: data.fontSize,
@@ -60,20 +50,6 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
         pixelRatio,
       });
 
-      if (data.showHandles) {
-        for (const handle of Object.values(data.handles)) {
-          drawHandle(
-            context,
-            handle.x * horizontalPixelRatio,
-            handle.y * verticalPixelRatio,
-            horizontalPixelRatio,
-            verticalPixelRatio,
-            colors.chartLineColor,
-            colors.chartBackground,
-          );
-        }
-      }
-
       context.restore();
     });
   }
@@ -83,7 +59,6 @@ function drawRectangleText(
   context: CanvasRenderingContext2D,
   params: {
     left: number;
-    right: number;
     top: number;
     text: string;
     fontSize: number;
@@ -107,7 +82,6 @@ function drawRectangleText(
   const fontStyle = isItalic ? 'italic ' : '';
   const textOffset = UI.textOffset * pixelRatio;
 
-  const textX = left;
   const blockHeight = lines.length * lineHeight;
   const firstLineY = top - textOffset - blockHeight + lineHeight / 2;
 
@@ -119,35 +93,8 @@ function drawRectangleText(
   context.textBaseline = 'middle';
 
   lines.forEach((line, index) => {
-    context.fillText(line, textX, firstLineY + index * lineHeight);
+    context.fillText(line, left, firstLineY + index * lineHeight);
   });
 
   context.restore();
 }
-
-function drawHandle(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  horizontalPixelRatio: number,
-  verticalPixelRatio: number,
-  strokeColor: string,
-  fillColor: string,
-): void {
-  const width = UI.handleSize * horizontalPixelRatio;
-  const height = UI.handleSize * verticalPixelRatio;
-  const left = x - width / 2;
-  const top = y - height / 2;
-
-  context.save();
-  context.fillStyle = fillColor;
-  context.strokeStyle = strokeColor;
-  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
-  context.beginPath();
-  context.rect(left, top, width, height);
-  context.fill();
-  context.stroke();
-
-  context.restore();
-}
diff --git a/src/core/Drawings/rectangle/rectangle.ts b/src/core/Drawings/rectangle/rectangle.ts
index e4102fc6a9ce987a7d2845a94c61b6b52fee30b0..82bb27a9708986410b73683dbb7bd6ea1c2a86bb 100644
--- a/src/core/Drawings/rectangle/rectangle.ts
+++ b/src/core/Drawings/rectangle/rectangle.ts
@@ -15,7 +15,6 @@ import {
   getTimeFromXCoordinate,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   isPointInBounds,
   normalizeBounds,
   shiftTimeByPixels,
@@ -37,6 +36,7 @@ import {
   RectangleTextStyle,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
@@ -67,24 +67,18 @@ interface RectangleGeometry {
   bottom: number;
   width: number;
   height: number;
-  handles: Record<RectangleHandleKey, Point>;
 }
 
-export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
-  showFill: boolean;
-  showHandles: boolean;
-}
+export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
 
-const HANDLE_HIT_TOLERANCE = 8;
 const BODY_HIT_TOLERANCE = 6;
 const MIN_RECTANGLE_SIZE = 6;
 
-export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
+export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHandleKey> implements ISeriesDrawing {
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
   protected settings: RectangleSettings = createDefaultSettings();
-
   protected mode: RectangleMode = 'idle';
 
   private startTime: Time | null = null;
@@ -276,12 +270,33 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
 
     return {
       ...geometry,
-      showFill: true,
-      showHandles: this.shouldShowHandles(),
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<RectangleHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    const { left, right, top, bottom } = geometry;
+    const centerX = (left + right) / 2;
+    const centerY = (top + bottom) / 2;
+
+    return [
+      { id: 'w', x: left, y: centerY, shape: 'rounded' },
+      { id: 'sw', x: left, y: bottom, shape: 'circle' },
+      { id: 's', x: centerX, y: bottom, shape: 'rounded' },
+      { id: 'se', x: right, y: bottom, shape: 'circle' },
+      { id: 'e', x: right, y: centerY, shape: 'rounded' },
+      { id: 'ne', x: right, y: top, shape: 'circle' },
+      { id: 'n', x: centerX, y: top, shape: 'rounded' },
+      { id: 'nw', x: left, y: top, shape: 'circle' },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
       return null;
@@ -301,7 +316,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
       };
     }
 
-    const handleTarget = this.getHandleTarget(point);
+    const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
 
     if (handleTarget) {
       return {
@@ -417,13 +432,9 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
       return;
     }
 
-    const rect = this.container.getBoundingClientRect();
-    const point = {
-      x: event.clientX - rect.left,
-      y: event.clientY - rect.top,
-    };
+    const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
+    if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
       return;
     }
 
@@ -569,7 +580,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
 
   private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
     this.mode = 'dragging';
-
     this.activeDragTarget = dragTarget;
     this.dragPointerId = pointerId;
     this.dragStartPoint = point;
@@ -609,7 +619,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
   }
 
   private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
-    const handleTarget = this.getHandleTarget(point);
+    const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
 
     if (handleTarget) {
       return handleTarget;
@@ -640,7 +650,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
     }
 
     const containerSize = this.getContainerSize();
-
     const rawOffsetX = point.x - this.dragStartPoint.x;
     const rawOffsetY = point.y - this.dragStartPoint.y;
 
@@ -760,9 +769,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
     const top = Math.round(Math.min(Number(startY), Number(endY)));
     const bottom = Math.round(Math.max(Number(startY), Number(endY)));
 
-    const centerX = (left + right) / 2;
-    const centerY = (top + bottom) / 2;
-
     return {
       left,
       right,
@@ -770,16 +776,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
       bottom,
       width: right - left,
       height: bottom - top,
-      handles: {
-        nw: { x: left, y: top },
-        n: { x: centerX, y: top },
-        ne: { x: right, y: top },
-        e: { x: right, y: centerY },
-        se: { x: right, y: bottom },
-        s: { x: centerX, y: bottom },
-        sw: { x: left, y: bottom },
-        w: { x: left, y: centerY },
-      },
     };
   }
 
@@ -896,26 +892,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
     return startIsTop ? this.endPrice : this.startPrice;
   }
 
-  private getHandleTarget(point: Point): RectangleHandleKey | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
-
-    for (const handleName of handleOrder) {
-      const handle = geometry.handles[handleName];
-
-      if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
-        return handleName;
-      }
-    }
-
-    return null;
-  }
-
   private containsPoint(point: Point): boolean {
     const geometry = this.getGeometry();
 
diff --git a/src/core/Drawings/regressionTrend/paneRenderer.ts b/src/core/Drawings/regressionTrend/paneRenderer.ts
index 7f4b9104273ee32e1f0235ebfa271c262b258d44..d3ea3dd5cee7ee44583cb78deafe1b579b374efd 100644
--- a/src/core/Drawings/regressionTrend/paneRenderer.ts
+++ b/src/core/Drawings/regressionTrend/paneRenderer.ts
@@ -1,8 +1,6 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import type { RegressionTrend } from './regressionTrend';
 
 import type { Point } from '@core/Drawings/types';
@@ -10,13 +8,8 @@ import type { Point } from '@core/Drawings/types';
 const UI = {
   lineWidth: 2,
   middleLineWidth: 1,
-
   middleLineDash: 5,
   middleLineGap: 4,
-
-  handleRadius: 5,
-  handleBorderWidth: 2,
-
   correlationFontSize: 12,
   correlationOffsetX: 0,
   correlationOffsetY: 6,
@@ -36,17 +29,13 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    const { colors } = getThemeStore();
-
     target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
 
       const baseStart = scalePoint(data.baseStartPoint, horizontalPixelRatio, verticalPixelRatio);
       const baseEnd = scalePoint(data.baseEndPoint, horizontalPixelRatio, verticalPixelRatio);
-
       const upperStart = scalePoint(data.upperStartPoint, horizontalPixelRatio, verticalPixelRatio);
       const upperEnd = scalePoint(data.upperEndPoint, horizontalPixelRatio, verticalPixelRatio);
-
       const lowerStart = scalePoint(data.lowerStartPoint, horizontalPixelRatio, verticalPixelRatio);
       const lowerEnd = scalePoint(data.lowerEndPoint, horizontalPixelRatio, verticalPixelRatio);
 
@@ -68,7 +57,6 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
 
         context.strokeStyle = data.middleLineColor;
         context.lineWidth = UI.middleLineWidth * pixelRatio;
-
         context.setLineDash([UI.middleLineDash * pixelRatio, UI.middleLineGap * pixelRatio]);
 
         drawLine(context, baseStart, baseEnd);
@@ -83,17 +71,6 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
         drawCorrelation(context, data.correlation, correlationPoint, correlationDirection, data.lineColor, pixelRatio);
       }
 
-      if (data.showHandles) {
-        context.fillStyle = colors.chartBackground;
-        context.strokeStyle = colors.chartLineColor;
-        context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
-        const radius = UI.handleRadius * pixelRatio;
-
-        drawHandle(context, baseStart, radius);
-        drawHandle(context, baseEnd, radius);
-      }
-
       context.restore();
     });
   }
@@ -125,24 +102,13 @@ function drawArea(
 
   context.beginPath();
   context.moveTo(first.x, first.y);
-
   context.lineTo(second.x, second.y);
   context.lineTo(third.x, third.y);
   context.lineTo(fourth.x, fourth.y);
-
   context.closePath();
   context.fill();
 }
 
-function drawHandle(context: CanvasRenderingContext2D, point: Point, radius: number): void {
-  context.beginPath();
-
-  context.arc(point.x, point.y, radius, 0, Math.PI * 2);
-
-  context.fill();
-  context.stroke();
-}
-
 function drawCorrelation(
   context: CanvasRenderingContext2D,
   correlation: number,
@@ -156,17 +122,14 @@ function drawCorrelation(
 
   context.save();
   context.font = `${UI.correlationFontSize * pixelRatio}px Inter, sans-serif`;
-
   context.fillStyle = color;
   context.textAlign = 'left';
 
   if (direction === 'upper') {
     context.textBaseline = 'bottom';
-
     context.fillText(correlation.toString(), point.x + offsetX, point.y - offsetY);
   } else {
     context.textBaseline = 'top';
-
     context.fillText(correlation.toString(), point.x + offsetX, point.y + offsetY);
   }
 
diff --git a/src/core/Drawings/regressionTrend/regressionTrend.ts b/src/core/Drawings/regressionTrend/regressionTrend.ts
index a9f81ef43d128996653d641939f7d4108d1a0a4a..659e6845a8d2537725704e1c81b45efd5191fec2 100644
--- a/src/core/Drawings/regressionTrend/regressionTrend.ts
+++ b/src/core/Drawings/regressionTrend/regressionTrend.ts
@@ -4,13 +4,8 @@ import {
   CustomTimeAxisPaneView,
   CustomTimeAxisView,
 } from '@core/Drawings/axis';
-import {
-  getTimeFromXCoordinate,
-  getXCoordinateFromTime,
-  getYCoordinateFromPrice,
-  isNearPoint,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
 import { getThemeStore } from '@src/theme';
@@ -26,6 +21,7 @@ import {
   RegressionTrendStyle,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
@@ -33,6 +29,7 @@ import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } fro
 
 type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
 type DragTarget = 'start' | 'end' | 'body' | null;
+type RegressionTrendHandleKey = Exclude<DragTarget, 'body' | null>;
 type TimeLabelKind = 'start' | 'end';
 type PriceLabelKind = 'start' | 'end';
 
@@ -55,31 +52,23 @@ interface RegressionSeriesData {
 interface RegressionResult {
   baseStartPrice: number;
   baseEndPrice: number;
-
   upperStartPrice: number;
   upperEndPrice: number;
-
   lowerStartPrice: number;
   lowerEndPrice: number;
-
   correlation: number;
 }
 
 interface RegressionTrendGeometry {
   baseStartPoint: Point;
   baseEndPoint: Point;
-
   upperStartPoint: Point;
   upperEndPoint: Point;
-
   lowerStartPoint: Point;
   lowerEndPoint: Point;
-
   baseStartPrice: number;
   baseEndPrice: number;
-
   correlation: number;
-
   left: number;
   right: number;
   top: number;
@@ -88,14 +77,16 @@ interface RegressionTrendGeometry {
 
 export interface RegressionTrendRenderData extends RegressionTrendGeometry, RegressionTrendStyle {
   showChannel: boolean;
-  showHandles: boolean;
 }
 
-const HIT_TOLERANCE = 8;
+const LINE_HIT_TOLERANCE = 8;
 const MIN_BAR_DISTANCE = 1;
 const REGRESSION_DEVIATION = 2;
 
-export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings> implements ISeriesDrawing {
+export class RegressionTrend
+  extends SeriesDrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
+  implements ISeriesDrawing
+{
   private openSettings?: () => void;
 
   protected settings: RegressionTrendSettings = createDefaultSettings();
@@ -116,13 +107,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
   };
 
   private paneView: RegressionTrendPaneView;
-
   private timeAxisPaneView: CustomTimeAxisPaneView;
   private priceAxisPaneView: CustomPriceAxisPaneView;
-
   private startTimeAxisView: CustomTimeAxisView;
   private endTimeAxisView: CustomTimeAxisView;
-
   private startPriceAxisView: CustomPriceAxisView;
   private endPriceAxisView: CustomPriceAxisView;
 
@@ -135,12 +123,7 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     openSettings,
     initialEvent,
   }: RegressionTrendParams) {
-    super({
-      chart,
-      series,
-      container,
-      interaction,
-    });
+    super({ chart, series, container, interaction });
 
     this.openSettings = openSettings;
 
@@ -245,10 +228,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       this.paneView,
       this.timeAxisPaneView,
       this.priceAxisPaneView,
-      this.startTimeAxisView,
-      this.endTimeAxisView,
-      this.startPriceAxisView,
-      this.endPriceAxisView,
+      ...this.timeAxisViews(),
+      ...this.priceAxisViews(),
     ]);
   }
 
@@ -287,10 +268,32 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       ...geometry,
       ...this.settings,
       showChannel: this.mode === 'ready' || this.mode === 'dragging',
-      showHandles: this.shouldShowHandles(),
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<RegressionTrendHandleKey>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'start',
+        ...geometry.baseStartPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+      {
+        id: 'end',
+        ...geometry.baseEndPoint,
+        shape: 'circle',
+        borderWidth: 2,
+      },
+    ];
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode !== 'ready') {
       return null;
@@ -373,7 +376,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const labelKind = kind as TimeLabelKind;
-
     const time = labelKind === 'start' ? this.startTime : this.endTime;
 
     if (typeof time !== 'number') {
@@ -407,7 +409,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const labelKind = kind as PriceLabelKind;
-
     const geometry = this.getGeometry();
 
     if (!geometry) {
@@ -415,7 +416,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const price = labelKind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
-
     const coordinate = getYCoordinateFromPrice(this.series, price);
 
     if (coordinate === null) {
@@ -439,7 +439,7 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
 
     const point = this.getEventPoint(event as PointerEvent);
 
-    if (!this.containsPoint(point)) {
+    if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
       return;
     }
 
@@ -459,8 +459,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     if (this.mode === 'idle') {
       event.preventDefault();
       event.stopPropagation();
-      this.startDrawing(point);
 
+      this.startDrawing(point);
       return;
     }
 
@@ -478,7 +478,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
 
       if (!this.hasValidRange()) {
         this.render();
-
         return;
       }
 
@@ -486,7 +485,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       this.resolveReady?.();
 
       this.render();
-
       return;
     }
 
@@ -503,7 +501,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       event.stopPropagation();
 
       this.select();
-
       return;
     }
 
@@ -511,7 +508,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
 
     if (!dragTarget) {
       this.deselect();
-
       return;
     }
 
@@ -566,7 +562,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     this.mode = 'ready';
 
     this.showCrosshair();
-
     this.render();
   };
 
@@ -583,10 +578,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
 
     const baseStartPoint = this.getPoint(this.startTime, regression.baseStartPrice);
     const baseEndPoint = this.getPoint(this.endTime, regression.baseEndPrice);
-
     const upperStartPoint = this.getPoint(this.startTime, regression.upperStartPrice);
     const upperEndPoint = this.getPoint(this.endTime, regression.upperEndPrice);
-
     const lowerStartPoint = this.getPoint(this.startTime, regression.lowerStartPrice);
     const lowerEndPoint = this.getPoint(this.endTime, regression.lowerEndPrice);
 
@@ -599,18 +592,13 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     return {
       baseStartPoint,
       baseEndPoint,
-
       upperStartPoint,
       upperEndPoint,
-
       lowerStartPoint,
       lowerEndPoint,
-
       baseStartPrice: regression.baseStartPrice,
       baseEndPrice: regression.baseEndPrice,
-
       correlation: regression.correlation,
-
       left: Math.min(...points.map((item) => item.x)),
       right: Math.max(...points.map((item) => item.x)),
       top: Math.min(...points.map((item) => item.y)),
@@ -643,15 +631,12 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       case 'start':
         this.moveEdge('start', point);
         break;
-
       case 'end':
         this.moveEdge('end', point);
         break;
-
       case 'body':
         this.moveWhole(snapshot, point);
         break;
-
       default:
         break;
     }
@@ -689,10 +674,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const data = this.getSeriesData();
-
     const dragStartIndex = this.getBarIndexByX(this.dragStartPoint.x, data);
     const currentIndex = this.getBarIndexByX(point.x, data);
-
     const startIndex = this.getBarIndex(snapshot.startTime, data);
     const endIndex = this.getBarIndex(snapshot.endTime, data);
 
@@ -701,13 +684,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const rawOffset = currentIndex - dragStartIndex;
-
     const minIndex = Math.min(startIndex, endIndex);
     const maxIndex = Math.max(startIndex, endIndex);
-
     const minOffset = -minIndex;
     const maxOffset = data.length - 1 - maxIndex;
-
     const offset = Math.max(minOffset, Math.min(rawOffset, maxOffset));
 
     this.startTime = data[startIndex + offset].time;
@@ -737,22 +717,17 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const regression = calculateRegression(values);
-
     const offset = regression.deviation * REGRESSION_DEVIATION;
-
     const baseStartPrice = range.reversed ? regression.endValue : regression.startValue;
     const baseEndPrice = range.reversed ? regression.startValue : regression.endValue;
 
     return {
       baseStartPrice,
       baseEndPrice,
-
       upperStartPrice: baseStartPrice + offset,
       upperEndPrice: baseEndPrice + offset,
-
       lowerStartPrice: baseStartPrice - offset,
       lowerEndPrice: baseEndPrice - offset,
-
       correlation: regression.correlation,
     };
   }
@@ -798,7 +773,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
     }
 
     const data = this.getSeriesData();
-
     const index = findNearestBarIndex(data, time);
 
     if (index === null) {
@@ -845,18 +819,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
   }
 
   private getDragTarget(point: Point): DragTarget {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
+    const handle = this.getDrawingHandleAtPoint(point);
 
-    if (isNearPoint(point, geometry.baseStartPoint.x, geometry.baseStartPoint.y, HIT_TOLERANCE)) {
-      return 'start';
-    }
-
-    if (isNearPoint(point, geometry.baseEndPoint.x, geometry.baseEndPoint.y, HIT_TOLERANCE)) {
-      return 'end';
+    if (handle) {
+      return handle.id;
     }
 
     if (this.containsPoint(point)) {
@@ -873,15 +839,15 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
       return false;
     }
 
-    if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= HIT_TOLERANCE) {
+    if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= LINE_HIT_TOLERANCE) {
       return true;
     }
 
-    if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= HIT_TOLERANCE) {
+    if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= LINE_HIT_TOLERANCE) {
       return true;
     }
 
-    if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= HIT_TOLERANCE) {
+    if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= LINE_HIT_TOLERANCE) {
       return true;
     }
 
@@ -901,7 +867,6 @@ function calculateRegression(values: readonly number[]): {
   correlation: number;
 } {
   const count = values.length;
-
   const meanX = (count - 1) / 2;
   const meanY = values.reduce((sum, value) => sum + value, 0) / count;
 
@@ -919,7 +884,6 @@ function calculateRegression(values: readonly number[]): {
   }
 
   const slope = sumXX === 0 ? 0 : sumXY / sumXX;
-
   const intercept = meanY - slope * meanX;
 
   let residualSum = 0;
@@ -936,9 +900,7 @@ function calculateRegression(values: readonly number[]): {
   return {
     startValue: intercept,
     endValue: intercept + slope * (count - 1),
-
     deviation: Math.sqrt(residualSum / count),
-
     correlation: denominator === 0 ? 0 : sumXY / denominator,
   };
 }
@@ -965,7 +927,6 @@ function findNearestBarIndex(data: readonly RegressionSeriesData[], targetTime:
 
   while (left <= right) {
     const middle = Math.floor((left + right) / 2);
-
     const { time } = data[middle];
 
     if (typeof time !== 'number') {
@@ -1018,25 +979,6 @@ function findNearestBarIndexLinear(data: readonly RegressionSeriesData[], target
   return result;
 }
 
-function getDistanceToSegment(point: Point, start: Point, end: Point): number {
-  const deltaX = end.x - start.x;
-  const deltaY = end.y - start.y;
-
-  if (deltaX === 0 && deltaY === 0) {
-    return Math.hypot(point.x - start.x, point.y - start.y);
-  }
-
-  const ratio = Math.max(
-    0,
-    Math.min(1, ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY)),
-  );
-
-  const x = start.x + ratio * deltaX;
-  const y = start.y + ratio * deltaY;
-
-  return Math.hypot(point.x - x, point.y - y);
-}
-
 function isPointInPolygon(point: Point, polygon: Point[]): boolean {
   let inside = false;
 
diff --git a/src/core/Drawings/ruler/paneRenderer.ts b/src/core/Drawings/ruler/paneRenderer.ts
index 1b191e4c94e52347207d2427ecdfef5ee4aaa6ed..2c7d9a14e0e19b1efb903f6d29b0581b4e15c3a3 100644
--- a/src/core/Drawings/ruler/paneRenderer.ts
+++ b/src/core/Drawings/ruler/paneRenderer.ts
@@ -1,6 +1,7 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
+import { drawRoundedRect } from '@core/Drawings/utils';
 import { Direction } from '@src/types';
 
 import type { Ruler } from './ruler';
@@ -199,21 +200,3 @@ function drawInfoBox(
 
   context.restore();
 }
-
-function drawRoundedRect(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  width: number,
-  height: number,
-  radius: number,
-): void {
-  const safeRadius = Math.min(radius, width / 2, height / 2);
-
-  context.moveTo(x + safeRadius, y);
-  context.arcTo(x + width, y, x + width, y + height, safeRadius);
-  context.arcTo(x + width, y + height, x, y + height, safeRadius);
-  context.arcTo(x, y + height, x, y, safeRadius);
-  context.arcTo(x, y, x + width, y, safeRadius);
-  context.closePath();
-}
diff --git a/src/core/Drawings/sliderPosition/paneRenderer.ts b/src/core/Drawings/sliderPosition/paneRenderer.ts
index 626494465becb3a6bd47120451ce0678ed0e43ba..a1df62f8258094d4f8f0cbcaea5c2fc86003df0e 100644
--- a/src/core/Drawings/sliderPosition/paneRenderer.ts
+++ b/src/core/Drawings/sliderPosition/paneRenderer.ts
@@ -1,7 +1,7 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
+import { drawRoundedRect } from '@core/Drawings/utils';
 
 import type { SliderPositionTextStyle } from './settings';
 import type { SliderPosition } from './sliderPosition';
@@ -14,9 +14,6 @@ const UI = {
   padding: 4,
   boxRadius: 4,
   labelOffset: 10,
-  handleSize: 10,
-  handleRadius: 3,
-  handleBorderWidth: 1,
 };
 
 export class SliderPaneRenderer implements IPrimitivePaneRenderer {
@@ -33,54 +30,53 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
       return;
     }
 
-    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
-      const startX = data.startX * horizontalPixelRatio;
-      const endX = data.endX * horizontalPixelRatio;
+    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
       const left = data.leftX * horizontalPixelRatio;
       const right = data.rightX * horizontalPixelRatio;
-
       const entryY = data.entryY * verticalPixelRatio;
       const stopY = data.stopY * verticalPixelRatio;
       const targetY = data.targetY * verticalPixelRatio;
-
       const profitTop = data.profitTop * verticalPixelRatio;
       const profitBottom = data.profitBottom * verticalPixelRatio;
       const lossTop = data.lossTop * verticalPixelRatio;
       const lossBottom = data.lossBottom * verticalPixelRatio;
-
       const centerX = (left + right) / 2;
-      const sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
-      const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
       const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
 
-      const targetLabelCenterY =
-        targetY < entryY
-          ? targetY - labelOffsetPx - sideBoxHeightPx / 2
-          : targetY + labelOffsetPx + sideBoxHeightPx / 2;
+      const targetBoxHeight = getTextBoxHeight(data.targetText, data, UI.sideBoxHeight, verticalPixelRatio);
+      const centerBoxHeight = getTextBoxHeight(data.centerText, data, UI.mainBoxHeight, verticalPixelRatio);
+      const stopBoxHeight = getTextBoxHeight(data.stopText, data, UI.sideBoxHeight, verticalPixelRatio);
+
+      const targetLabelCenterY = getOuterLabelCenterY(
+        targetY,
+        data.targetLabelDirection,
+        targetBoxHeight,
+        labelOffsetPx,
+      );
 
-      const stopLabelCenterY =
-        stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;
+      const stopLabelCenterY = getOuterLabelCenterY(stopY, data.stopLabelDirection, stopBoxHeight, labelOffsetPx);
+
+      const centerLabelCenterY = getCenterLabelCenterY(
+        entryY,
+        targetY,
+        stopY,
+        centerBoxHeight,
+        labelOffsetPx,
+        bitmapSize.height,
+      );
 
       context.save();
 
-      if (data.showFill) {
-        context.fillStyle = data.positiveFillColor;
-        context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
+      context.fillStyle = data.positiveFillColor;
+      context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
 
-        context.fillStyle = data.negativeFillColor;
-        context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
-      }
+      context.fillStyle = data.negativeFillColor;
+      context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
 
       context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
       context.strokeStyle = data.lineColor;
-      drawHorizontalLine(context, left, right, entryY);
 
-      if (data.showHandles) {
-        drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
-        drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
-        drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
-        drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
-      }
+      drawHorizontalLine(context, left, right, entryY);
 
       if (data.showLabels) {
         drawTextBox(
@@ -98,7 +94,7 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
         drawTextBox(
           context,
           centerX,
-          entryY + labelOffsetPx + mainBoxHeightPx / 2,
+          centerLabelCenterY,
           data.centerText,
           data.centerBoxColor,
           data,
@@ -125,51 +121,54 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
   }
 }
 
-function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
-  context.beginPath();
-  context.moveTo(left, y);
-  context.lineTo(right, y);
-  context.stroke();
+function getOuterLabelCenterY(
+  coordinate: number,
+  direction: 'up' | 'down',
+  boxHeight: number,
+  labelOffset: number,
+): number {
+  const offset = labelOffset + boxHeight / 2;
+
+  return direction === 'up' ? coordinate - offset : coordinate + offset;
 }
 
-function drawHandle(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  horizontalPixelRatio: number,
-  verticalPixelRatio: number,
-  shape: 'circle' | 'rounded',
-): void {
-  const width = UI.handleSize * horizontalPixelRatio;
-  const height = UI.handleSize * verticalPixelRatio;
-  const left = x - width / 2;
-  const top = y - height / 2;
+function getCenterLabelCenterY(
+  entryY: number,
+  targetY: number,
+  stopY: number,
+  boxHeight: number,
+  labelOffset: number,
+  viewportHeight: number,
+): number {
+  const targetSpace = Math.abs(targetY - entryY);
+  const stopSpace = Math.abs(stopY - entryY);
+
+  let direction: -1 | 1;
+
+  if (targetSpace < stopSpace) {
+    direction = stopY < entryY ? -1 : 1;
+  } else {
+    direction = targetY < entryY ? -1 : 1;
+  }
 
-  const { colors } = getThemeStore();
+  return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
+}
 
-  context.save();
-  context.fillStyle = colors.chartBackground;
-  context.strokeStyle = colors.chartLineColor;
-  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
+function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
+  if (viewportHeight <= boxHeight) {
+    return viewportHeight / 2;
+  }
 
-  context.beginPath();
+  const halfHeight = boxHeight / 2;
 
-  if (shape === 'circle') {
-    context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
-  } else {
-    drawRoundedRect(
-      context,
-      left,
-      top,
-      width,
-      height,
-      UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
-    );
-  }
+  return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
+}
 
-  context.fill();
+function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
+  context.beginPath();
+  context.moveTo(left, y);
+  context.lineTo(right, y);
   context.stroke();
-  context.restore();
 }
 
 function drawTextBox(
@@ -189,9 +188,7 @@ function drawTextBox(
   const fontSize = textStyle.fontSize * verticalPixelRatio;
   const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
   const paddingX = UI.padding * horizontalPixelRatio;
-  const paddingY = UI.padding * verticalPixelRatio;
-  const minBoxHeight = fixedHeight * verticalPixelRatio;
-  const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
+  const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
   const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
 
   context.font = getTextFont(textStyle, fontSize);
@@ -231,27 +228,23 @@ function drawTextBox(
   context.restore();
 }
 
+function getTextBoxHeight(
+  text: string,
+  textStyle: SliderPositionTextStyle,
+  fixedHeight: number,
+  verticalPixelRatio: number,
+): number {
+  const linesCount = text.split('\n').length;
+  const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
+  const paddingY = UI.padding * verticalPixelRatio;
+  const minBoxHeight = fixedHeight * verticalPixelRatio;
+
+  return Math.max(minBoxHeight, linesCount * lineHeight + paddingY * 2);
+}
+
 function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
   const italic = style.isItalic ? 'italic ' : '';
   const bold = style.isBold ? '700 ' : '';
 
   return `${italic}${bold}${fontSize}px Inter, sans-serif`;
 }
-
-function drawRoundedRect(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  width: number,
-  height: number,
-  radius: number,
-): void {
-  const safeRadius = Math.min(radius, width / 2, height / 2);
-
-  context.moveTo(x + safeRadius, y);
-  context.arcTo(x + width, y, x + width, y + height, safeRadius);
-  context.arcTo(x + width, y + height, x, y + height, safeRadius);
-  context.arcTo(x, y + height, x, y, safeRadius);
-  context.arcTo(x, y, x + width, y, safeRadius);
-  context.closePath();
-}
diff --git a/src/core/Drawings/sliderPosition/sliderPosition.ts b/src/core/Drawings/sliderPosition/sliderPosition.ts
index d6c9450cdbd8013b1757808c5c5dcdb42af82238..7d3e7ae82134cfa68559857175af819d8f7cb8e1 100644
--- a/src/core/Drawings/sliderPosition/sliderPosition.ts
+++ b/src/core/Drawings/sliderPosition/sliderPosition.ts
@@ -13,7 +13,6 @@ import {
   getTimeFromXCoordinate,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   isPointInBounds,
   shiftTimeByPixels,
 } from '@core/Drawings/helpers';
@@ -27,7 +26,6 @@ import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
 import { formatDate } from '@src/utils/formatter';
 
 import { SliderPaneView } from './paneView';
-
 import {
   createDefaultSettings,
   getSliderPositionSettingsTabs,
@@ -36,6 +34,7 @@ import {
   SliderPositionTextStyle,
 } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { ChartOptionsModel, SettingsTab } from '@src/types';
@@ -51,6 +50,7 @@ import type {
 type SliderSide = 'long' | 'short';
 type SliderMode = 'idle' | 'ready' | 'dragging';
 type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
+type SliderHandleId = Exclude<DragTarget, 'body' | null>;
 type TimeLabelKind = 'start' | 'end';
 type PriceLabelKind = 'target' | 'entry' | 'stop';
 
@@ -95,16 +95,19 @@ export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, S
   centerText: string;
   stopText: string;
   centerBoxColor: string;
-  showFill: boolean;
-  showHandles: boolean;
+  targetLabelDirection: 'up' | 'down';
+  stopLabelDirection: 'up' | 'down';
   showLabels: boolean;
 }
 
-const HIT_TOLERANCE = 8;
+const BODY_HIT_TOLERANCE = 8;
 const INITIAL_WIDTH_PX = 160;
 const MIN_DISTANCE = 0.00000001;
 
-export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
+export class SliderPosition
+  extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
+  implements ISeriesDrawing
+{
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
@@ -119,7 +122,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
   protected mode: SliderMode = 'idle';
 
   private side: SliderSide;
-
   private startTime: Time | null = null;
   private endTime: Time | null = null;
   private entryPrice: number | null = null;
@@ -157,27 +159,23 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     openSettings,
     initialEvent,
   }: SliderPositionParams) {
-    super({
-      chart,
-      series,
-      container,
-      interaction,
-    });
+    super({ chart, series, container, interaction });
 
     this.side = side;
     this.removeSelf = removeSelf;
     this.openSettings = openSettings;
-
     this.clickHandler = (params) => this.handleChartClick(params);
 
     this.paneView = new SliderPaneView(this);
 
     this.timeAxisPaneView = new CustomTimeAxisPaneView({
       getAxisSegments: () => this.getTimeAxisSegments(),
+      zOrder: 'normal',
     });
 
     this.priceAxisPaneView = new CustomPriceAxisPaneView({
       getAxisSegments: () => this.getPriceAxisSegments(),
+      zOrder: 'normal',
     });
 
     this.startTimeAxisView = new CustomTimeAxisView({
@@ -258,7 +256,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
     this.hidden = next.hidden ?? this.hidden;
     this.mode = next.mode ?? this.mode;
-
     this.startTime = next.startTime ?? this.startTime;
     this.endTime = next.endTime ?? this.endTime;
     this.entryPrice = next.entryPrice ?? this.entryPrice;
@@ -275,7 +272,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       this.entryPrice !== null &&
       this.stopPrice !== null &&
       typeof next.riskRewardRatio === 'number' &&
-      next.riskRewardRatio > 0
+      next.riskRewardRatio >= 0
     ) {
       const risk = Math.abs(this.entryPrice - this.stopPrice);
 
@@ -345,8 +342,8 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
     const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
     const qty = reward > 0 ? this.amount / reward : 0;
-
     const selectedPrice = this.getPriceAtTime(this.endTime);
+
     const pnl =
       selectedPrice === null
         ? 0
@@ -362,8 +359,8 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       centerText: this.getCenterText(qty, pnl),
       stopText: this.getStopText(geometry),
       centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
-      showFill: true,
-      showHandles: this.shouldShowHandles(),
+      targetLabelDirection: this.side === 'long' ? 'up' : 'down',
+      stopLabelDirection: this.side === 'long' ? 'down' : 'up',
       showLabels: this.isSelected(),
       ...this.settings,
     };
@@ -622,7 +619,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
     event.preventDefault();
     event.stopPropagation();
-
     this.openSettings?.();
   };
 
@@ -654,7 +650,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
     this.mode = 'ready';
     this.resolveReady?.();
-
     this.render();
   }
 
@@ -672,7 +667,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
       event.preventDefault();
       event.stopPropagation();
-
       this.select();
       return;
     }
@@ -700,7 +694,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     }
 
     event.preventDefault();
-
     this.applyDrag(this.getEventPoint(event));
     this.render();
   };
@@ -717,7 +710,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
 
     this.mode = 'ready';
     this.resolveReady?.();
-
     this.render();
   };
 
@@ -760,15 +752,10 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       return;
     }
 
-    const minDistance = this.getEditMinimumDistance();
-    const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
-    const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
+    const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
+    const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);
 
-    if (low > high) {
-      return;
-    }
-
-    this.entryPrice = Math.max(low, Math.min(nextPrice, high));
+    this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
   }
 
   private moveWhole(snapshot: SliderPositionState, point: Point): void {
@@ -839,11 +826,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       return;
     }
 
-    this.stopPrice = this.normalizeStop(
-      snapshot.entryPrice,
-      this.clampPriceToRange(nextPrice),
-      this.getEditMinimumDistance(),
-    );
+    this.stopPrice = this.normalizeStop(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
   }
 
   private moveTarget(snapshot: SliderPositionState, point: Point): void {
@@ -857,11 +840,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       return;
     }
 
-    this.targetPrice = this.normalizeTarget(
-      snapshot.entryPrice,
-      this.clampPriceToRange(nextPrice),
-      this.getEditMinimumDistance(),
-    );
+    this.targetPrice = this.normalizeTarget(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
   }
 
   private resizeEnd(snapshot: SliderPositionState, point: Point): void {
@@ -892,13 +871,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     };
   }
 
-  private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
+  private normalizeStop(entryPrice: number, rawPrice: number, minDistance = 0): number {
     const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
 
     return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
   }
 
-  private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
+  private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
     const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
 
     return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
@@ -950,10 +929,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
   }
 
-  private getEditMinimumDistance(): number {
-    return Math.max(this.tickSize, MIN_DISTANCE);
-  }
-
   private getPriceScaleRange(): { min: number; max: number } | null {
     return getPriceRangeInContainer(this.series, this.container);
   }
@@ -972,12 +947,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     }
 
     const risk = Math.abs(this.entryPrice - this.stopPrice);
+    const reward = Math.abs(this.targetPrice - this.entryPrice);
 
-    if (!risk) {
-      return this.defaultRiskRewardRatio;
+    if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
+      return 0;
     }
 
-    return Math.abs(this.targetPrice - this.entryPrice) / risk;
+    return reward / risk;
   }
 
   private getPriceAtTime(time: Time | null): number | null {
@@ -1010,6 +986,38 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
     return lastPrice;
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return [
+      {
+        id: 'target',
+        x: geometry.startX,
+        y: geometry.targetY,
+      },
+      {
+        id: 'stop',
+        x: geometry.startX,
+        y: geometry.stopY,
+      },
+      {
+        id: 'end',
+        x: geometry.endX,
+        y: geometry.entryY,
+      },
+      {
+        id: 'entry',
+        x: geometry.startX,
+        y: geometry.entryY,
+        shape: 'circle',
+      },
+    ];
+  }
+
   protected getGeometry(): SliderGeometry | null {
     if (this.startTime === null || this.endTime === null) {
       return null;
@@ -1088,43 +1096,26 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
   }
 
   private getHandleTarget(point: Point): DragTarget {
-    const geometry = this.getGeometry();
+    const handle = this.getDrawingHandleAtPoint(point);
 
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
-      return 'entry';
+    if (handle) {
+      return handle.id;
     }
 
-    if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
-      return 'target';
-    }
-
-    if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
-      return 'stop';
-    }
+    const geometry = this.getGeometry();
 
-    if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
-      return 'end';
+    if (!geometry) {
+      return null;
     }
 
-    const minY = Math.min(geometry.targetY, geometry.stopY);
-    const maxY = Math.max(geometry.targetY, geometry.stopY);
-
     const bounds: Bounds = {
       left: geometry.leftX,
       right: geometry.rightX,
-      top: minY,
-      bottom: maxY,
+      top: Math.min(geometry.targetY, geometry.stopY),
+      bottom: Math.max(geometry.targetY, geometry.stopY),
     };
 
-    if (isPointInBounds(point, bounds)) {
-      return 'body';
-    }
-
-    return null;
+    return isPointInBounds(point, bounds) ? 'body' : null;
   }
 
   private containsPoint(point: Point): boolean {
@@ -1134,16 +1125,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
       return false;
     }
 
-    const minY = Math.min(geometry.targetY, geometry.stopY);
-    const maxY = Math.max(geometry.targetY, geometry.stopY);
-
     const bounds: Bounds = {
       left: geometry.leftX,
       right: geometry.rightX,
-      top: minY,
-      bottom: maxY,
+      top: Math.min(geometry.targetY, geometry.stopY),
+      bottom: Math.max(geometry.targetY, geometry.stopY),
     };
 
-    return isPointInBounds(point, bounds, HIT_TOLERANCE);
+    return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
   }
 }
diff --git a/src/core/Drawings/text/paneRenderer.ts b/src/core/Drawings/text/paneRenderer.ts
index 5576be418ca7de6e5cafbb491ca67c4be9420caf..91a0b7c32e8a448587e37ac427163ed4ed1b35c2 100644
--- a/src/core/Drawings/text/paneRenderer.ts
+++ b/src/core/Drawings/text/paneRenderer.ts
@@ -1,6 +1,7 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
+import { drawRoundedRect } from '@core/Drawings/utils';
 import { getThemeStore } from '@src/theme';
 
 import { Text } from './text';
@@ -99,7 +100,7 @@ function fillRoundedRect(
   radius: number,
 ): void {
   context.beginPath();
-  buildRoundedRectPath(context, x, y, width, height, radius);
+  drawRoundedRect(context, x, y, width, height, radius);
   context.fill();
 }
 
@@ -112,24 +113,6 @@ function strokeRoundedRect(
   radius: number,
 ): void {
   context.beginPath();
-  buildRoundedRectPath(context, x, y, width, height, radius);
+  drawRoundedRect(context, x, y, width, height, radius);
   context.stroke();
 }
-
-function buildRoundedRectPath(
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  width: number,
-  height: number,
-  radius: number,
-): void {
-  const safeRadius = Math.min(radius, width / 2, height / 2);
-
-  context.moveTo(x + safeRadius, y);
-  context.arcTo(x + width, y, x + width, y + height, safeRadius);
-  context.arcTo(x + width, y + height, x, y + height, safeRadius);
-  context.arcTo(x, y + height, x, y, safeRadius);
-  context.arcTo(x, y, x + width, y, safeRadius);
-  context.closePath();
-}
diff --git a/src/core/Drawings/traectory/paneRenderer.ts b/src/core/Drawings/traectory/paneRenderer.ts
index bb631bad223d92942c025c84cd29f8f92d923889..ca2d43113b01d91e8c7b51fbdb0c5169c28226b7 100644
--- a/src/core/Drawings/traectory/paneRenderer.ts
+++ b/src/core/Drawings/traectory/paneRenderer.ts
@@ -1,15 +1,11 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import { Traectory } from './traectory';
 
 const UI = {
   lineWidth: 2,
   previewLineWidth: 1,
-  handleRadius: 5,
-  handleBorderWidth: 2,
   previewDash: [6, 4],
   arrowLength: 14,
   arrowHalfWidth: 6,
@@ -33,8 +29,6 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
       const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
       const lineWidth = UI.lineWidth * pixelRatio;
       const previewLineWidth = UI.previewLineWidth * pixelRatio;
-      const handleRadius = UI.handleRadius * pixelRatio;
-      const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
       const arrowLength = UI.arrowLength * pixelRatio;
       const arrowHalfWidth = UI.arrowHalfWidth * pixelRatio;
 
@@ -68,6 +62,7 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
         const lastPoint = data.points[data.points.length - 1];
 
         context.lineWidth = lineWidth;
+
         drawArrowHead(
           context,
           previousPoint.x * horizontalPixelRatio,
@@ -92,30 +87,11 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
         context.restore();
       }
 
-      if (data.showHandles) {
-        const { colors } = getThemeStore();
-
-        context.fillStyle = colors.chartBackground;
-        context.strokeStyle = colors.chartLineColor;
-        context.lineWidth = handleBorderWidth;
-
-        data.points.forEach((point) => {
-          drawHandle(context, point.x * horizontalPixelRatio, point.y * verticalPixelRatio, handleRadius);
-        });
-      }
-
       context.restore();
     });
   }
 }
 
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
-  context.beginPath();
-  context.arc(x, y, radius, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-}
-
 function drawArrowHead(
   context: CanvasRenderingContext2D,
   fromX: number,
@@ -135,7 +111,6 @@ function drawArrowHead(
 
   const directionX = dx / distance;
   const directionY = dy / distance;
-
   const normalX = -directionY;
   const normalY = directionX;
 
diff --git a/src/core/Drawings/traectory/traectory.ts b/src/core/Drawings/traectory/traectory.ts
index 733448e72939ce60de45a7989d8f02116fa19360..38e7872f0dac7ff648bb3458eef76597e66f3976 100644
--- a/src/core/Drawings/traectory/traectory.ts
+++ b/src/core/Drawings/traectory/traectory.ts
@@ -11,7 +11,7 @@ import {
   isNearPoint,
 } from '@core/Drawings/helpers';
 import { AxisLabel } from '@core/Drawings/types';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
 import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
 
 import { getThemeStore } from '@src/theme';
@@ -20,11 +20,13 @@ import { TraectoryPaneView } from './paneView';
 
 import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
 
+import type { DrawingHandle } from '@core/Drawings/handles';
 import type { Anchor, AxisSegment, Point } from '@core/Drawings/types';
 import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
 import type { SettingsTab } from '@src/types';
 
 type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
+type TraectoryHandleId = `${number}`;
 
 type TraectoryParams = BaseDrawingParams;
 
@@ -45,7 +47,6 @@ interface TraectoryGeometry {
 
 export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
   previewPoint: Point | null;
-  showHandles: boolean;
   showArrow: boolean;
 }
 
@@ -53,12 +54,11 @@ const POINT_HIT_TOLERANCE = 8;
 const SEGMENT_HIT_TOLERANCE = 6;
 const MIN_POINTS_COUNT = 2;
 
-export class Traectory extends SeriesDrawingBase<TraectorySettings> implements ISeriesDrawing {
+export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHandleId> implements ISeriesDrawing {
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
   protected settings: TraectorySettings = createDefaultSettings();
-
   protected mode: TraectoryMode = 'idle';
 
   private points: Anchor[] = [];
@@ -178,21 +178,49 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
     return {
       ...geometry,
       previewPoint: this.getPreviewPoint(),
-      showHandles: this.shouldShowHandles(),
       showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
       ...this.settings,
     };
   }
 
+  protected getDrawingHandles(): readonly DrawingHandle<TraectoryHandleId>[] {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return [];
+    }
+
+    return geometry.points
+      .map<DrawingHandle<TraectoryHandleId>>((point, index) => ({
+        id: `${index}`,
+        ...point,
+        shape: 'circle',
+        size: 10,
+        borderWidth: 2,
+      }))
+      .reverse();
+  }
+
   protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
     if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
       return null;
     }
 
     const point = { x, y };
-    const pointIndex = this.getPointIndexAt(point);
 
-    if (pointIndex !== null) {
+    if (!this.isSelected()) {
+      if (!this.isPointNearTraectory(point)) {
+        return null;
+      }
+
+      return {
+        cursorStyle: 'move',
+        externalId: 'traectory',
+        zOrder: 'top',
+      };
+    }
+
+    if (this.getPointIndexAt(point) !== null) {
       return {
         cursorStyle: 'move',
         externalId: 'traectory',
@@ -339,11 +367,8 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
       return;
     }
 
-    const pointIndex = this.getPointIndexAt(point);
-    const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);
-
     if (!this.isSelected()) {
-      if (!isInsideTraectory) {
+      if (!this.isPointNearTraectory(point)) {
         return;
       }
 
@@ -354,6 +379,8 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
       return;
     }
 
+    const pointIndex = this.getPointIndexAt(point);
+
     if (pointIndex !== null) {
       event.preventDefault();
       event.stopPropagation();
@@ -642,15 +669,9 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
   }
 
   private getPointIndexAt(point: Point): number | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));
+    const handle = this.getDrawingHandleAtPoint(point);
 
-    return index >= 0 ? index : null;
+    return handle ? Number(handle.id) : null;
   }
 
   private isPointNearTraectory(point: Point): boolean {
@@ -670,7 +691,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
       const startPoint = geometry.points[index];
       const endPoint = geometry.points[index + 1];
 
-      if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
+      if (getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
         return true;
       }
     }
@@ -678,25 +699,6 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
     return false;
   }
 
-  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
-    const dx = endPoint.x - startPoint.x;
-    const dy = endPoint.y - startPoint.y;
-
-    if (dx === 0 && dy === 0) {
-      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
-    }
-
-    const t = Math.max(
-      0,
-      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
-    );
-
-    const projectionX = startPoint.x + t * dx;
-    const projectionY = startPoint.y + t * dy;
-
-    return Math.hypot(point.x - projectionX, point.y - projectionY);
-  }
-
   private getContainerSize(): { width: number; height: number } {
     return getElementContainerSize(this.container);
   }
diff --git a/src/core/Drawings/utils.ts b/src/core/Drawings/utils.ts
index b9a1f5fb47d07793265c48a92879719bce7c2fea..a00fdf76fd8222319e2578fd891a5a0bf8baf12b 100644
--- a/src/core/Drawings/utils.ts
+++ b/src/core/Drawings/utils.ts
@@ -1,4 +1,4 @@
-import type { UpdatableView } from './types';
+import type { Point, UpdatableView } from './types';
 
 export function updateViews(views: readonly UpdatableView[]): void {
   for (const view of views) {
@@ -29,3 +29,40 @@ export function getOrderedSideValue<T>(
 
   return startFirst ? endValue : startValue;
 }
+
+export function drawRoundedRect(
+  context: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  radius: number,
+): void {
+  const safeRadius = Math.min(radius, width / 2, height / 2);
+
+  context.moveTo(x + safeRadius, y);
+  context.arcTo(x + width, y, x + width, y + height, safeRadius);
+  context.arcTo(x + width, y + height, x, y + height, safeRadius);
+  context.arcTo(x, y + height, x, y, safeRadius);
+  context.arcTo(x, y, x + width, y, safeRadius);
+  context.closePath();
+}
+
+export function getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
+  const dx = endPoint.x - startPoint.x;
+  const dy = endPoint.y - startPoint.y;
+
+  if (dx === 0 && dy === 0) {
+    return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
+  }
+
+  const t = Math.max(
+    0,
+    Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
+  );
+
+  const projectionX = startPoint.x + t * dx;
+  const projectionY = startPoint.y + t * dy;
+
+  return Math.hypot(point.x - projectionX, point.y - projectionY);
+}
diff --git a/src/core/Drawings/volumeProfile/paneRenderer.ts b/src/core/Drawings/volumeProfile/paneRenderer.ts
index 34ec06d9527778fac4ef50bd258c24682965838c..ae0b011bc356a6750bfba2a1fbb3de0fd14c927a 100644
--- a/src/core/Drawings/volumeProfile/paneRenderer.ts
+++ b/src/core/Drawings/volumeProfile/paneRenderer.ts
@@ -1,13 +1,9 @@
 import { CanvasRenderingTarget2D } from 'fancy-canvas';
 import { IPrimitivePaneRenderer } from 'lightweight-charts';
 
-import { getThemeStore } from '@src/theme';
-
 import type { VolumeProfile, VolumeProfileKind } from './volumeProfile';
 
 const UI = {
-  handleRadius: 5,
-  handleBorderWidth: 2,
   rowGap: 1,
   pocLineWidth: 2,
 };
@@ -68,21 +64,6 @@ export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
         const pocY = data.pocY * verticalPixelRatio;
 
         drawPocLine(context, data.profileKind, left, right, pocY, pixelRatio, data.pocLineColor);
-
-        if (data.showHandles && data.profileKind === 'visibleRange') {
-          drawHandle(context, left, pocY, pixelRatio);
-        }
-      }
-
-      if (data.showHandles && data.profileKind === 'fixedRange') {
-        drawHandle(
-          context,
-          data.startPoint.x * horizontalPixelRatio,
-          data.startPoint.y * verticalPixelRatio,
-          pixelRatio,
-        );
-
-        drawHandle(context, data.endPoint.x * horizontalPixelRatio, data.endPoint.y * verticalPixelRatio, pixelRatio);
       }
 
       context.restore();
@@ -156,20 +137,3 @@ function drawPocLine(
   context.lineTo(lineRight, y);
   context.stroke();
 }
-
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, pixelRatio: number): void {
-  context.save();
-
-  const { colors } = getThemeStore();
-
-  context.fillStyle = colors.chartBackground;
-  context.strokeStyle = colors.chartLineColor;
-  context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
-  context.beginPath();
-  context.arc(x, y, UI.handleRadius * pixelRatio, 0, Math.PI * 2);
-  context.fill();
-  context.stroke();
-
-  context.restore();
-}
diff --git a/src/core/Drawings/volumeProfile/volumeProfile.ts b/src/core/Drawings/volumeProfile/volumeProfile.ts
index 60066238661b2fbde4dd259f51336449ea4e66a4..1c2ba0dd30e4efc2d74a9d55e904a860b6bb0441 100644
--- a/src/core/Drawings/volumeProfile/volumeProfile.ts
+++ b/src/core/Drawings/volumeProfile/volumeProfile.ts
@@ -13,7 +13,6 @@ import {
   getContainerSize as getElementContainerSize,
   getXCoordinateFromTime,
   getYCoordinateFromPrice,
-  isNearPoint,
   isPointInBounds,
 } from '@core/Drawings/helpers';
 import { updateViews } from '@core/Drawings/utils';
@@ -34,6 +33,7 @@ import {
   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';
@@ -42,6 +42,7 @@ 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;
@@ -100,16 +101,17 @@ export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumePr
   profileKind: VolumeProfileKind;
   rows: VolumeProfileRenderRow[];
   pocY: number | null;
-  showHandles: boolean;
 }
 
 const PROFILE_ROW_COUNT = 24;
-const HANDLE_HIT_TOLERANCE = 8;
 const BODY_HIT_TOLERANCE = 4;
 const MIN_PROFILE_SIZE = 8;
 const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
 
-export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> implements ISeriesDrawing {
+export class VolumeProfile
+  extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
+  implements ISeriesDrawing
+{
   private removeSelf?: () => void;
   private openSettings?: () => void;
 
@@ -358,11 +360,33 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
       profileKind: this.profileKind,
       rows,
       pocY,
-      showHandles: this.shouldShowHandles(),
       ...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', size: 10, borderWidth: 2 }];
+    }
+
+    return [
+      { id: 'end', ...geometry.endPoint, shape: 'circle', size: 10, borderWidth: 2 },
+      { id: 'start', ...geometry.startPoint, shape: 'circle', size: 10, borderWidth: 2 },
+    ];
+  }
+
   protected getTimeAxisSegments(): AxisSegment[] {
     if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
       return [];
@@ -543,7 +567,7 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
   };
 
   protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
-    const dragTarget = this.getVisibleRangeDragTarget(point);
+    let dragTarget = this.getVisibleRangeDragTarget(point);
 
     if (!dragTarget) {
       if (this.isSelected()) {
@@ -558,6 +582,7 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
 
     if (!this.isSelected()) {
       this.select();
+      dragTarget = this.getVisibleRangeDragTarget(point);
     }
 
     if (dragTarget !== 'poc') {
@@ -849,15 +874,9 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
   }
 
   private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    const pocY = this.getPocY(geometry);
+    const handle = this.getDrawingHandleAtPoint(point);
 
-    if (pocY !== null && isNearPoint(point, geometry.left, pocY, HANDLE_HIT_TOLERANCE)) {
+    if (handle?.id === 'poc') {
       return 'poc';
     }
 
@@ -869,18 +888,10 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
   }
 
   private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
-    const geometry = this.getGeometry();
-
-    if (!geometry) {
-      return null;
-    }
-
-    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
-      return 'start';
-    }
+    const handle = this.getDrawingHandleAtPoint(point);
 
-    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
-      return 'end';
+    if (handle) {
+      return handle.id;
     }
 
     if (this.containsPoint(point)) {
diff --git a/tsconfig.json b/tsconfig.json
index 27c78cf1810ec0e064002d9a61b8c82781735ddd..c19bc2f96a6a7d6eb405fb879b8b26035bb0de0a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,6 +30,6 @@
     "resolveJsonModule": true,
     "sourceMap": true
   },
-  "include": ["src", "stories", ".storybook", "jest.config.ts"],
+  "include": ["src", "stories", ".storybook", "jest.config.ts", "jest.setup.ts"],
   "exclude": ["dist", "node_modules"]
 }