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


import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { SliderPaneView } from './paneView';
import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
  SliderPositionSettings,
  SliderPositionStyle,
  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';
import type {
  IPrimitivePaneView,
  MouseEventHandler,
  MouseEventParams,
  PrimitiveHoveredItem,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

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';

interface SliderPositionParams extends BaseDrawingParams {
  side: SliderSide;
  resetTriggers?: Observable<unknown>[];
}

interface SliderPositionState {
  hidden: boolean;
  mode: SliderMode;
  startTime: Time | null;
  endTime: Time | null;
  entryPrice: number | null;
  stopPrice: number | null;
  targetPrice: number | null;
  riskRewardRatio: number;
  amount: number;
  riskSize: number;
  pointValue: number;
  lotSize: number;
  leverage: number | null;
  quantityPrecision: number;
  tickSize: number;
  settings: SliderPositionSettings;
}

interface SliderGeometry {
  startX: number;
  endX: number;
  leftX: number;
  rightX: number;
  entryY: number;
  stopY: number;
  targetY: number;
  entryPrice: number;
  stopPrice: number;
  targetPrice: number;
  profitTop: number;
  profitBottom: number;
  lossTop: number;
  lossBottom: number;
}

export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
  targetText: string;
  centerText: string;
  stopText: string;
  centerBoxColor: string;
  targetLabelDirection: 'up' | 'down';
  stopLabelDirection: 'up' | 'down';
  showLabels: boolean;
}

const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const INITIAL_ZONE_OFFSET_PX = 60;
const DEFAULT_ACCOUNT_SIZE = 1000;
const DEFAULT_RISK_SIZE = 250;
const DEFAULT_POINT_VALUE = 1;
const DEFAULT_LOT_SIZE = 1;
const DEFAULT_QUANTITY_PRECISION = 3;
const AMOUNT_PRECISION = 2;
const RATIO_PRECISION = 2;
const TICKS_PRECISION = 2;
const MIN_DISTANCE = 0.00000001;

export class SliderPosition
  extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: SliderPositionSettings = createDefaultSettings();

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  protected mode: SliderMode = 'idle';

  private side: SliderSide;
  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private entryPrice: number | null = null;
  private stopPrice: number | null = null;
  private targetPrice: number | null = null;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: SliderPositionState | null = null;

  private defaultRiskRewardRatio = 1;
  private accountSize = DEFAULT_ACCOUNT_SIZE;
  private riskSize = DEFAULT_RISK_SIZE;
  private pointValue = DEFAULT_POINT_VALUE;
  private lotSize = DEFAULT_LOT_SIZE;
  private leverage: number | null = null;
  private quantityPrecision = DEFAULT_QUANTITY_PRECISION;
  private tickSize = 1;

  private clickHandler: MouseEventHandler<Time>;
  private paneView: SliderPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;
  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;
  private targetPriceAxisView: CustomPriceAxisView;
  private entryPriceAxisView: CustomPriceAxisView;
  private stopPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    side,
    container,
    interaction,
    formatObservable,
    resetTriggers = [],
    removeSelf,
    openSettings,
    initialEvent,
  }: SliderPositionParams) {
    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({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'start',
    });

    this.endTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'end',
    });

    this.targetPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'target',
    });

    this.entryPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'entry',
    });

    this.stopPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'stop',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);

    if (initialEvent) {
      this.handleChartClick(initialEvent);
    }
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle';
  }

  public getState(): SliderPositionState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      riskRewardRatio: this.getCurrentRiskRewardRatio(),
      amount: this.accountSize,
      riskSize: this.riskSize,
      pointValue: this.pointValue,
      lotSize: this.lotSize,
      leverage: this.leverage,
      quantityPrecision: this.quantityPrecision,
      tickSize: this.getPriceStep(),
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const next = state as Partial<SliderPositionState>;

    this.hidden = next.hidden ?? this.hidden;
    this.mode = next.mode ?? this.mode;
    this.applyCalculationState(next);

    if (next.startTime !== undefined) {
      this.startTime = next.startTime;
    }

    if (next.endTime !== undefined) {
      this.endTime = next.endTime;
    }

    if (next.entryPrice !== undefined) {
      this.entryPrice = next.entryPrice;
    }

    if (next.stopPrice !== undefined) {
      this.stopPrice =
        next.stopPrice === null || this.entryPrice === null
          ? next.stopPrice
          : this.normalizeStop(this.entryPrice, next.stopPrice);
    }

    if (next.targetPrice !== undefined) {
      this.targetPrice =
        next.targetPrice === null || this.entryPrice === null
          ? next.targetPrice
          : this.normalizeTarget(this.entryPrice, next.targetPrice);
    } else if (
      this.entryPrice !== null &&
      this.stopPrice !== null &&
      typeof next.riskRewardRatio === 'number' &&
      next.riskRewardRatio >= 0
    ) {
      const risk = Math.abs(this.entryPrice - this.stopPrice);
      const direction = this.side === 'long' ? 1 : -1;
      const targetPrice = this.entryPrice + risk * next.riskRewardRatio * direction;

      this.targetPrice = this.normalizeTarget(this.entryPrice, targetPrice);
    }

    if ('settings' in next && next.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...next.settings,
      };
    }

    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getSliderPositionSettingsTabs(this.settings);
  }

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.targetPriceAxisView,
      this.entryPriceAxisView,
      this.stopPriceAxisView,
    ]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.timeAxisPaneView];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.priceAxisPaneView];
  }

  public timeAxisViews() {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
  }

  public getRenderData(): SliderRenderData | null {
    if (this.hidden) {
      return null;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const risk = Math.abs(geometry.entryPrice - geometry.stopPrice);
    const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const qty = this.getPositionQuantity(risk, geometry.entryPrice);
    const multiplier = this.pointValue * this.lotSize;
    const targetAmount = this.accountSize + reward * qty * multiplier;
    const stopAmount = this.accountSize - risk * qty * multiplier;
    const selectedPrice = this.getPriceAtTime(this.endTime);

    const pnl =
      selectedPrice === null
        ? 0
        : this.side === 'long'
          ? (selectedPrice - geometry.entryPrice) * qty * multiplier
          : (geometry.entryPrice - selectedPrice) * qty * multiplier;

    const { colors } = getThemeStore();

    return {
      ...geometry,
      targetText: this.getTargetText(geometry, targetAmount),
      centerText: this.getCenterText(qty, pnl),
      stopText: this.getStopText(geometry, stopAmount),
      centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
      targetLabelDirection: this.side === 'long' ? 'up' : 'down',
      stopLabelDirection: this.side === 'long' ? 'down' : 'up',
      showLabels: this.isSelected(),
      ...this.settings,
    };
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const start = this.getTimeCoordinate('start');
    const end = this.getTimeCoordinate('end');

    if (start === null || end === null) {
      return null;
    }

    return {
      left: Math.min(start, end),
      right: Math.max(start, end),
    };
  }

  public getTimeCoordinate(kind: TimeLabelKind): number | null {
    const time = kind === 'start' ? this.startTime : this.endTime;

    if (time === null) {
      return null;
    }

    const coordinate = getXCoordinateFromTime(this.chart, time, this.series);

    return coordinate === null ? null : Number(coordinate);
  }

  public getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  public getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    switch (kind) {
      case 'target':
        return geometry.targetY;
      case 'entry':
        return geometry.entryY;
      case 'stop':
        return geometry.stopY;
      default:
        return null;
    }
  }

  public getPriceText(kind: PriceLabelKind): string {
    const geometry = this.getGeometry();

    if (!geometry) {
      return '';
    }

    switch (kind) {
      case 'target':
        return formatPrice(geometry.targetPrice) ?? '';
      case 'entry':
        return formatPrice(geometry.entryPrice) ?? '';
      case 'stop':
        return formatPrice(geometry.stopPrice) ?? '';
      default:
        return '';
    }
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    const point = { x, y };

    if (!this.isSelected()) {
      if (!this.containsPoint(point)) {
        return null;
      }

      return {
        cursorStyle: 'pointer',
        externalId: 'slider-position',
        zOrder: 'top',
      };
    }

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      return null;
    }

    let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';

    if (dragTarget === 'target' || dragTarget === 'stop') {
      cursorStyle = 'ns-resize';
    }

    if (dragTarget === 'end') {
      cursorStyle = 'ew-resize';
    }

    return {
      cursorStyle,
      externalId: 'slider-position',
      zOrder: 'top',
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isSelected()) {
      return [];
    }

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.left,
        to: bounds.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    if (!this.isSelected()) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.profitTop,
        to: geometry.profitBottom,
        color: colors.axisMarkerAreaFill,
      },
      {
        from: geometry.lossTop,
        to: geometry.lossBottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    let backgroundColor = colors.axisMarkerLabelDefaultFill;

    if (labelKind === 'target') {
      backgroundColor = colors.axisMarkerLabelPositiveFill;
    }

    if (labelKind === 'stop') {
      backgroundColor = colors.axisMarkerLabelNegativeFill;
    }

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor,
    };
  }

  protected bindEvents(): void {
    if (this.isBound) {
      return;
    }

    super.bindEvents();
    this.chart.subscribeClick(this.clickHandler);
  }

  protected unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.chart.unsubscribeClick(this.clickHandler);
    super.unbindEvents();
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
      return;
    }

    const point = this.getEventPoint(event as PointerEvent);

    if (!this.containsPoint(point)) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    this.openSettings?.();
  };

  private handleChartClick(params: MouseEventParams<Time>): void {
    // todo: привести к общему виду дровингов как handleChartClick => startDrawing
    if (this.hidden || !params.point || this.mode !== 'idle') {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const distance = this.getInitialZoneDistance(anchor.price, params.point.y);

    if (distance === null) {
      this.mode = 'ready';
      this.resolveReady?.();
      this.removeSelf?.();
      return;
    }

    const stopDirection = this.side === 'long' ? -1 : 1;
    const targetDirection = -stopDirection;

    this.startTime = anchor.time;
    this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
    this.entryPrice = anchor.price;
    this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection);
    this.targetPrice = this.normalizeTarget(
      anchor.price,
      anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
    );

    this.mode = 'ready';
    this.resolveReady?.();
    this.render();
  }

  protected handlePointerDown = (event: PointerEvent): void => {
    if (this.hidden || this.mode !== 'ready' || event.button !== 0) {
      return;
    }

    const point = this.getEventPoint(event);

    if (!this.isSelected()) {
      if (!this.containsPoint(point)) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();
      this.select();
      return;
    }

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      this.deselect();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.mode = 'dragging';
  };

  protected handlePointerMove = (event: PointerEvent): void => {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
      return;
    }

    event.preventDefault();
    this.applyDrag(this.getEventPoint(event));
    this.render();
  };

  protected handlePointerUp = (event: PointerEvent): void => {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

    this.mode = 'ready';
    this.resolveReady?.();
    this.render();
  };

  private applyDrag(point: Point): void {
    const snapshot = this.dragStateSnapshot;

    if (!snapshot) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'body':
        this.moveWhole(snapshot, point);
        break;
      case 'entry':
        this.moveEntry(snapshot, point);
        break;
      case 'target':
        this.moveTarget(snapshot, point);
        break;
      case 'stop':
        this.moveStop(snapshot, point);
        break;
      case 'end':
        this.resizeEnd(snapshot, point);
        break;
      default:
        break;
    }
  }

  private moveEntry(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    const priceStep = this.getPriceStep();
    const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice) + priceStep;
    const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice) - priceStep;

    if (minPrice > maxPrice) {
      return;
    }

    this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
  }

  private moveWhole(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.startTime === null || snapshot.endTime === null) {
      return;
    }

    if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

    if (!this.dragStartPoint) {
      return;
    }

    const timeOffset = point.x - this.dragStartPoint.x;
    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);

    const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
    const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);

    if (nextStartTime === null || nextEndTime === null) {
      return;
    }

    let nextEntryPrice = snapshot.entryPrice + priceOffset;
    let nextStopPrice = snapshot.stopPrice + priceOffset;
    let nextTargetPrice = snapshot.targetPrice + priceOffset;

    const range = this.getPriceScaleRange();

    if (range) {
      const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
      const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);

      if (minValue < range.min) {
        const shift = range.min - minValue;

        nextEntryPrice += shift;
        nextStopPrice += shift;
        nextTargetPrice += shift;
      }

      if (maxValue > range.max) {
        const shift = maxValue - range.max;

        nextEntryPrice -= shift;
        nextStopPrice -= shift;
        nextTargetPrice -= shift;
      }
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.entryPrice = nextEntryPrice;
    this.stopPrice = nextStopPrice;
    this.targetPrice = nextTargetPrice;
  }

  private moveStop(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    this.stopPrice = this.normalizeStop(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
  }

  private moveTarget(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    this.targetPrice = this.normalizeTarget(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
  }

  private resizeEnd(snapshot: SliderPositionState, point: Point): void {
    const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);

    if (nextEndTime === null) {
      return;
    }

    this.startTime = snapshot.startTime;
    this.endTime = nextEndTime;
  }

  private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
    if (!params.point || params.time === undefined) {
      return null;
    }

    const price = getPriceFromYCoordinate(this.series, params.point.y);

    if (price === null) {
      return null;
    }

    return {
      time: params.time,
      price,
    };
  }

  private applyCalculationState(next: Partial<SliderPositionState>): void {
    if (typeof next.amount === 'number' && Number.isFinite(next.amount) && next.amount >= 0) {
      this.accountSize = next.amount;
    }

    if (typeof next.riskSize === 'number' && Number.isFinite(next.riskSize) && next.riskSize >= 0) {
      this.riskSize = next.riskSize;
    }

    if (typeof next.pointValue === 'number' && Number.isFinite(next.pointValue) && next.pointValue > 0) {
      this.pointValue = next.pointValue;
    }

    if (typeof next.lotSize === 'number' && Number.isFinite(next.lotSize) && next.lotSize > 0) {
      this.lotSize = next.lotSize;
    }

    if (next.leverage === null) {
      this.leverage = null;
    } else if (typeof next.leverage === 'number' && Number.isFinite(next.leverage) && next.leverage > 0) {
      this.leverage = next.leverage;
    }

    if (typeof next.quantityPrecision === 'number' && Number.isFinite(next.quantityPrecision)) {
      this.quantityPrecision = Math.max(0, Math.min(16, Math.trunc(next.quantityPrecision)));
    }

    if (typeof next.tickSize === 'number' && Number.isFinite(next.tickSize) && next.tickSize > 0) {
      this.tickSize = next.tickSize;
    }
  }

  private normalizeStop(entryPrice: number, rawPrice: number): number {
    const priceStep = this.getPriceStep();

    return this.side === 'long'
      ? Math.min(rawPrice, entryPrice - priceStep)
      : Math.max(rawPrice, entryPrice + priceStep);
  }

  private normalizeTarget(entryPrice: number, rawPrice: number): number {
    const priceStep = this.getPriceStep();

    return this.side === 'long'
      ? Math.max(rawPrice, entryPrice + priceStep)
      : Math.min(rawPrice, entryPrice - priceStep);
  }

  private clampPriceToRange(price: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return price;
    }

    return Math.max(range.min, Math.min(price, range.max));
  }

  private getInitialZoneDistance(entryPrice: number, entryY: number): number | null {
    const { height } = this.container.getBoundingClientRect();
    const availableOffset = Math.min(entryY, height - entryY) - HIT_TOLERANCE;
    const offset = Math.min(INITIAL_ZONE_OFFSET_PX, availableOffset);

    if (offset <= 0) {
      return null;
    }

    const upperPrice = getPriceFromYCoordinate(this.series, entryY - offset);
    const lowerPrice = getPriceFromYCoordinate(this.series, entryY + offset);

    if (upperPrice === null || lowerPrice === null) {
      return null;
    }

    const minPrice = Math.min(upperPrice, lowerPrice);
    const maxPrice = Math.max(upperPrice, lowerPrice);
    const stopDistance =
      this.side === 'long' ? entryPrice - minPrice : maxPrice - entryPrice;
    const targetDistance =
      this.side === 'long' ? maxPrice - entryPrice : entryPrice - minPrice;

    if (stopDistance <= 0 || targetDistance <= 0) {
      return null;
    }

    const riskRewardRatio = Math.max(this.defaultRiskRewardRatio, MIN_DISTANCE);
    const distance = Math.min(stopDistance, targetDistance / riskRewardRatio);
    const priceStep = this.getPriceStep();
    const minDistance = Math.max(priceStep, priceStep / riskRewardRatio);

    return distance >= minDistance ? distance : null;
  }

  private getPriceStep(): number {
    const priceFormat = this.series.options?.().priceFormat;
    const minMove = priceFormat && 'minMove' in priceFormat ? priceFormat.minMove : undefined;

    return typeof minMove === 'number' && minMove > 0
      ? minMove
      : Math.max(this.tickSize, MIN_DISTANCE);
  }

  private getPriceScaleRange(): { min: number; max: number } | null {
    return getPriceRangeInContainer(this.series, this.container);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getPositionQuantity(risk: number, entryPrice: number): number {
    if (risk <= MIN_DISTANCE) {
      return 0;
    }

    const qtyRisk = this.riskSize / (risk * this.pointValue) / this.lotSize;

    if (this.leverage === null) {
      return qtyRisk;
    }

    const safeEntryPrice = Math.abs(entryPrice);

    if (safeEntryPrice <= MIN_DISTANCE) {
      return 0;
    }

    const qtyLeverage =
      ((this.accountSize * this.leverage) / safeEntryPrice) * this.pointValue / this.lotSize;

    return Math.min(qtyRisk, qtyLeverage);
  }

  private getCurrentRiskRewardRatio(): number {
    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return this.defaultRiskRewardRatio;
    }

    const risk = Math.abs(this.entryPrice - this.stopPrice);

    if (risk <= MIN_DISTANCE) {
      return 0;
    }

    return Math.abs(this.targetPrice - this.entryPrice) / risk;
  }

  private getPriceAtTime(time: Time | null): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    const data = this.series.data() ?? [];
    let lastPrice: number | null = null;

    for (const item of data) {
      if (typeof item.time !== 'number') {
        continue;
      }

      if (item.time > time) {
        break;
      }

      if ('close' in item && typeof item.close === 'number') {
        lastPrice = item.close;
        continue;
      }

      if ('value' in item && typeof item.value === 'number') {
        lastPrice = item.value;
      }
    }

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

    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
    const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
    const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);

    if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
      return null;
    }

    const start = Number(startX);
    const end = Number(endX);
    const entry = Number(entryY);
    const stop = Number(stopY);
    const target = Number(targetY);

    return {
      startX: start,
      endX: end,
      leftX: Math.min(start, end),
      rightX: Math.max(start, end),
      entryY: entry,
      stopY: stop,
      targetY: target,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      profitTop: Math.min(target, entry),
      profitBottom: Math.max(target, entry),
      lossTop: Math.min(stop, entry),
      lossBottom: Math.max(stop, entry),
    };
  }

  private getTargetText(geometry: SliderGeometry, amount: number): string {
    const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = diff / this.getPriceStep();

    const formattedDiff = formatPrice(diff) ?? '0';
    const formattedTicks = formatMetric(ticks, TICKS_PRECISION);
    const formattedAmount = formatMetric(amount, AMOUNT_PRECISION);

    return [
      `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}`,
      `${t('Amount')}: ${formattedAmount}`,
    ].join(', ');
  }

  private getCenterText(qty: number, pnl: number): string {
    const formattedQty = formatMetric(qty, this.quantityPrecision);
    const formattedRatio = formatMetric(this.getCurrentRiskRewardRatio(), RATIO_PRECISION);

    return [
      `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}`,
      `${t('Risk/Reward Ratio')}: ${formattedRatio}`,
    ].join('\n');
  }

  private getStopText(geometry: SliderGeometry, amount: number): string {
    const diff = Math.abs(geometry.stopPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = diff / this.getPriceStep();

    const formattedDiff = formatPrice(diff) ?? '0';
    const formattedTicks = formatMetric(ticks, TICKS_PRECISION);
    const formattedAmount = formatMetric(amount, AMOUNT_PRECISION);

    return [
      `${t('Stop')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}`,
      `${t('Amount')}: ${formattedAmount}`,
    ].join(', ');
  }

  private getHandleTarget(point: Point): DragTarget {
    const handle = this.getDrawingHandleAtPoint(point);
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (handle?.id === 'entry' && point.y !== geometry.entryY) {
      const pointerSide = Math.sign(point.y - geometry.entryY);

      if (
        pointerSide === Math.sign(geometry.targetY - geometry.entryY) &&
        Math.abs(point.y - geometry.targetY) <= HIT_TOLERANCE
      ) {
        return 'target';
      }

      if (
        pointerSide === Math.sign(geometry.stopY - geometry.entryY) &&
        Math.abs(point.y - geometry.stopY) <= HIT_TOLERANCE
      ) {
        return 'stop';
      }
    }

    if (handle) {
      return handle.id;
    }

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: Math.min(geometry.targetY, geometry.stopY),
      bottom: Math.max(geometry.targetY, geometry.stopY),
    };

    return isPointInBounds(point, bounds) ? 'body' : null;
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: Math.min(geometry.targetY, geometry.stopY),
      bottom: Math.max(geometry.targetY, geometry.stopY),
    };

    return isPointInBounds(point, bounds, HIT_TOLERANCE);
  }
}

function formatMetric(value: number, maximumFractionDigits: number): string {
  if (!Number.isFinite(value)) {
    return '0';
  }

  const threshold = 0.5 / 10 ** maximumFractionDigits;
  const normalized = Math.abs(value) < threshold ? 0 : value;

  return normalized.toLocaleString(undefined, {
    maximumFractionDigits,
    useGrouping: false,
  });
}







import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { drawRoundedRect } from '@core/Drawings/utils';

import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';

const UI = {
  lineWidth: 1,
  lineHeightMultiplier: 1.2,
  mainBoxHeight: 28,
  sideBoxHeight: 16,
  padding: 4,
  boxRadius: 4,
  labelOffset: 10,
};

export class SliderPaneRenderer implements IPrimitivePaneRenderer {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.slider.getRenderData();

    if (!data) {
      return;
    }

    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 labelOffsetPx = UI.labelOffset * verticalPixelRatio;

      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,
        bitmapSize.height,
      );

      const stopLabelCenterY = getOuterLabelCenterY(
        stopY,
        data.stopLabelDirection,
        stopBoxHeight,
        labelOffsetPx,
        bitmapSize.height,
      );

      const centerLabelCenterY = getCenterLabelCenterY(
        entryY,
        targetY,
        stopY,
        centerBoxHeight,
        labelOffsetPx,
        bitmapSize.height,
      );

      context.save();

      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.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
      context.strokeStyle = data.lineColor;

      drawHorizontalLine(context, left, right, entryY);

      if (data.showLabels) {
        drawTextBox(
          context,
          centerX,
          targetLabelCenterY,
          data.targetText,
          data.positiveFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          centerLabelCenterY,
          data.centerText,
          data.centerBoxColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.mainBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          stopLabelCenterY,
          data.stopText,
          data.negativeFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );
      }

      context.restore();
    });
  }
}

function getOuterLabelCenterY(
  coordinate: number,
  direction: 'up' | 'down',
  boxHeight: number,
  labelOffset: number,
  viewportHeight: number,
): number {
  const offset = labelOffset + boxHeight / 2;
  const center = direction === 'up' ? coordinate - offset : coordinate + offset;

  return clampTextBoxCenter(center, boxHeight, viewportHeight);
}

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

  return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
}

function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
  if (viewportHeight <= boxHeight) {
    return viewportHeight / 2;
  }

  const halfHeight = boxHeight / 2;

  return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
}

function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();
}

function drawTextBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  text: string,
  fillColor: string,
  textStyle: SliderPositionTextStyle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fixedHeight: number,
): void {
  context.save();

  const lines = text.split('\n');
  const fontSize = textStyle.fontSize * verticalPixelRatio;
  const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
  const paddingX = UI.padding * horizontalPixelRatio;
  const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
  const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.font = getTextFont(textStyle, fontSize);
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  let maxTextWidth = 0;

  for (const line of lines) {
    maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
  }

  const width = maxTextWidth + paddingX * 2;
  const halfWidth = width / 2;
  const safeCenterX = Math.max(halfWidth, Math.min(centerX, context.canvas.width - halfWidth));
  const x = safeCenterX - halfWidth;
  const y = centerY - boxHeight / 2;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, x, y, width, boxHeight, radius);
  context.fill();

  context.fillStyle = textStyle.textColor;

  if (lines.length === 1) {
    context.fillText(lines[0], safeCenterX, centerY);
    context.restore();
    return;
  }

  const textBlockHeight = lines.length * lineHeight;
  const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(line, safeCenterX, firstLineCenterY + index * lineHeight);
  });

  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`;
}








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 { Coordinate, MouseEventParams, 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,
    riskSize: 250,
    pointValue: 1,
    lotSize: 1,
    leverage: null,
    quantityPrecision: 3,
    tickSize: 1,
  });
}

function createInitialEvent(x: number, y: number, time: Time): MouseEventParams<Time> {
  return {
    point: { x, y },
    time,
  } as unknown as MouseEventParams<Time>;
}

describe('SliderPosition', () => {
  it('считает Risk/Reward Ratio', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.getState().riskRewardRatio).toBe(2);
  });

  it('считает Qty и Amount по формулам TradingView', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: null,
      quantityPrecision: 3,
      tickSize: 1,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%) 10, Amount: 1250',
      centerText: 'Open P&L: 250, Qty: 25\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%) 10, Amount: 750',
    });
  });

  it('не округляет Qty до расчёта Amount', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 1000,
      stopPrice: 500.6,
      targetPrice: 1145.1,
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: null,
      quantityPrecision: 3,
      tickSize: 0.01,
    });

    const data = slider.getRenderData();

    expect(slider.getState().riskRewardRatio).toBeCloseTo(0.2905486584);
    expect(data?.centerText).toContain('Qty: 0.501');
    expect(data?.centerText).toContain('Risk/Reward Ratio: 0.29');
    expect(data?.targetText).toContain('Amount: 1072.64');
    expect(data?.stopText).toContain('Amount: 750');
  });

  it('изменение Target не меняет Qty и Stop Amount', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 120,
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: null,
      quantityPrecision: 3,
      tickSize: 1,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 20 (20%) 20, Amount: 1500',
      centerText: 'Open P&L: 250, Qty: 25\nRisk/Reward Ratio: 2',
      stopText: 'Stop: 10 (10%) 10, Amount: 750',
    });
  });

  it('учитывает ограничение Qty по leverage как TradingView', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: 1,
      quantityPrecision: 3,
      tickSize: 1,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%) 10, Amount: 1100',
      centerText: 'Open P&L: 100, Qty: 10\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%) 10, Amount: 900',
    });
  });

  it('учитывает Point value и Lot size в Qty и Amount', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      amount: 1000,
      riskSize: 250,
      pointValue: 2,
      lotSize: 5,
      leverage: null,
      quantityPrecision: 3,
      tickSize: 1,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%) 10, Amount: 1250',
      centerText: 'Open P&L: 250, Qty: 2.5\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%) 10, Amount: 750',
    });
  });

  it('считает long и short симметрично', () => {
    const { slider } = createSlider('short');

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 90,
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: null,
      quantityPrecision: 3,
      tickSize: 1,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%) 10, Amount: 1250',
      centerText: 'Open P&L: -250, Qty: 25\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%) 10, Amount: 750',
    });
  });

  it('нормализует параметры расчёта из state', () => {
    const { slider } = createSlider();

    slider.setState({
      amount: -1,
      riskSize: Number.NaN,
      pointValue: -1,
      lotSize: 0,
      leverage: Number.POSITIVE_INFINITY,
      quantityPrecision: 100,
    });

    expect(slider.getState()).toMatchObject({
      amount: 1000,
      riskSize: 250,
      pointValue: 1,
      lotSize: 1,
      leverage: null,
      quantityPrecision: 16,
    });
  });

  it('не схлопывает Target с Entry при RR = 0', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      riskRewardRatio: 0,
      tickSize: 1,
    });

    expect(slider.getState()).toMatchObject({
      targetPrice: 101,
      riskRewardRatio: 0.1,
    });
  });

  it('использует minMove серии как минимальный шаг цены', () => {
    const { slider, series } = createSlider();

    Object.assign(series, {
      options: jest.fn(() => ({
        priceFormat: {
          type: 'price',
          precision: 2,
          minMove: 0.01,
        },
      })),
    });

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      riskRewardRatio: 0,
      tickSize: 1,
    });

    expect(slider.getState().targetPrice).toBeCloseTo(100.01);
    expect(slider.getState().riskRewardRatio).toBeCloseTo(0.001);
  });

  it('нормализует Target, совпадающий с Entry', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 100,
      tickSize: 1,
    });

    expect(slider.getState().targetPrice).toBe(101);
  });

  it('нормализует Stop, совпадающий с Entry', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 100,
      targetPrice: 120,
      tickSize: 1,
    });

    expect(slider.getState().stopPrice).toBe(99);
  });

  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 имеет приоритет при полном визуальном совпадении с Target', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 101,
      tickSize: 1,
    });

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 101 ? 100 : price) as Coordinate;
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 95));

    expect(slider.getState()).toMatchObject({
      entryPrice: 95,
      targetPrice: 101,
    });
  });

  it('Entry имеет приоритет при полном визуальном совпадении со Stop', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 99,
      targetPrice: 120,
      tickSize: 1,
    });

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 99 ? 100 : price) as Coordinate;
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 110));

    expect(slider.getState()).toMatchObject({
      entryPrice: 110,
      stopPrice: 99,
    });
  });

  it('выбирает Target на его стороне при пересечении hit-зон с Entry', () => {
    const { slider, container } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 101,
      tickSize: 1,
    });

    slider.pointerDown(createPointerEvent(container, 10, 101));
    slider.pointerMove(createPointerEvent(container, 10, 110));

    expect(slider.getState().targetPrice).toBe(110);
  });

  it('выбирает Stop на его стороне при пересечении hit-зон с Entry', () => {
    const { slider, container } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 99,
      targetPrice: 120,
      tickSize: 1,
    });

    slider.pointerDown(createPointerEvent(container, 10, 99));
    slider.pointerMove(createPointerEvent(container, 10, 90));

    expect(slider.getState().stopPrice).toBe(90);
  });

  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(119);
  });

  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(99);
  });

  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(101);
  });

  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('ограничивает Stop и Target минимальным шагом для short позиции', () => {
    const { slider, container } = createSlider('short', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 80,
      tickSize: 1,
    });

    slider.pointerDown(createPointerEvent(container, 10, 80));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().targetPrice).toBe(99);

    slider.pointerUp(createPointerEvent(container, 10, 100));
    slider.pointerDown(createPointerEvent(container, 10, 110));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().stopPrice).toBe(101);
  });

  it.each(['long', 'short'] as const)('создаёт %s позицию с ограниченной высотой', (side) => {
    const seriesData = [{ time: 150 as Time, close: 150 }];
    const env = createDrawingTestEnvironment({
      seriesData,
      height: 300,
    });

    const slider = new TestSliderPosition({
      ...env,
      side,
      initialEvent: createInitialEvent(100, 150, 150 as Time),
    });

    const priceHandles = slider.handles().filter(({ id }) => {
      return id === 'target' || id === 'stop';
    });

    expect(slider.getState().mode).toBe('ready');
    expect(priceHandles).toHaveLength(2);
    expect(priceHandles.every(({ y }) => Math.abs(y - 150) <= 60)).toBe(true);
  });

  it.each([
    ['long', 20],
    ['long', 280],
    ['short', 20],
    ['short', 280],
  ] as const)('не выводит handles %s позиции за pane при y=%s', (side, entryY) => {
    const seriesData = [{ time: entryY as Time, close: entryY }];
    const env = createDrawingTestEnvironment({
      seriesData,
      height: 300,
    });

    const slider = new TestSliderPosition({
      ...env,
      side,
      initialEvent: createInitialEvent(100, entryY, entryY as Time),
    });

    const priceHandles = slider.handles().filter(({ id }) => {
      return id === 'target' || id === 'stop';
    });

    expect(slider.getState().mode).toBe('ready');
    expect(priceHandles.every(({ y }) => y >= 8 && y <= 292)).toBe(true);
  });

  it('удаляет creation, если для зон недостаточно места', () => {
    const seriesData = [{ time: 4 as Time, close: 4 }];
    const env = createDrawingTestEnvironment({
      seriesData,
      height: 300,
    });
    const removeSelf = jest.fn();

    const slider = new TestSliderPosition({
      ...env,
      side: 'long',
      removeSelf,
      initialEvent: createInitialEvent(100, 4, 4 as Time),
    });

    expect(slider.getState().mode).toBe('ready');
    expect(removeSelf).toHaveBeenCalledTimes(1);
  });

  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('не выпускает labels за границы canvas', () => {
    const data: SliderRenderData = {
      startX: -10,
      endX: 30,
      leftX: -10,
      rightX: 30,
      entryY: 50,
      stopY: 98,
      targetY: 2,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      profitTop: 2,
      profitBottom: 50,
      lossTop: 50,
      lossBottom: 98,
      targetText: 'very long target label',
      centerText: 'center\nsecond line',
      stopText: 'very long stop label',
      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({
      horizontalPixelRatio: 1,
      verticalPixelRatio: 1,
      bitmapWidth: 200,
      bitmapHeight: 100,
    });

    new SliderPaneRenderer({
      getRenderData: () => data,
    } as unknown as SliderPosition).draw(target);

    for (const [, x, y] of context.fillText.mock.calls) {
      expect(x).toBeGreaterThanOrEqual(0);
      expect(x).toBeLessThanOrEqual(200);
      expect(y).toBeGreaterThanOrEqual(0);
      expect(y).toBeLessThanOrEqual(100);
    }
  });

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