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


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface SliderPositionStyle {
  lineColor: string;
  lineWidth: number;
  positiveFillColor: string;
  negativeFillColor: string;
  textColor: string;
  fontSize: number;
  showPriceLabels: boolean;
  alwaysShowStats: boolean;
}

export interface SliderPositionCalculationSettings {
  accountSize: number;
  risk: number;
  lotSize: number;
  leverage: number;
  quantityPrecision: number;
}

export interface SliderPositionArguments {
  entryPrice: number;
  targetPrice: number;
  stopPrice: number;
}

export type SliderPositionSettings =
  SettingsValues &
  SliderPositionStyle &
  SliderPositionCalculationSettings;

export function createDefaultSettings(): SliderPositionSettings {
  const { colors } = getThemeStore();

  return {
    accountSize: 1000,
    risk: 25,
    lotSize: 1,
    leverage: 10000,
    quantityPrecision: 3,
    lineColor: colors.chartCrosshairLine,
    lineWidth: 1,
    positiveFillColor: colors.sliderPositiveFill,
    negativeFillColor: colors.sliderNegativeFill,
    textColor: colors.chartPriceLineText,
    fontSize: 10,
    showPriceLabels: true,
    alwaysShowStats: false,
  };
}

export function getSliderPositionSettingsTabs(
  settings: SliderPositionSettings,
  position: SliderPositionArguments,
): SettingsTab[] {
  const inputFields: SettingField[] = [
    {
      key: 'accountSize',
      label: t('Account size'),
      type: 'number',
      defaultValue: settings.accountSize,
      min: 0,
    },
    {
      key: 'lotSize',
      label: t('Lot size'),
      type: 'number',
      defaultValue: settings.lotSize,
      min: 0.00000001,
    },
    {
      key: 'risk',
      label: t('Risk, %'),
      type: 'number',
      defaultValue: settings.risk,
      min: 0,
    },
    {
      key: 'entryPrice',
      label: t('Entry price'),
      type: 'number',
      defaultValue: position.entryPrice,
    },
    {
      key: 'leverage',
      label: t('Leverage'),
      type: 'number',
      defaultValue: settings.leverage,
      min: 1,
    },
    {
      key: 'targetPrice',
      label: t('Profit price'),
      type: 'number',
      defaultValue: position.targetPrice,
    },
    {
      key: 'stopPrice',
      label: t('Stop price'),
      type: 'number',
      defaultValue: position.stopPrice,
    },
    {
      key: 'quantityPrecision',
      label: t('QTY precision'),
      type: 'number',
      defaultValue: settings.quantityPrecision,
      min: 0,
      max: 16,
    },
  ];

  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
    },
    {
      key: 'lineWidth',
      label: t('Line width'),
      type: 'number',
      defaultValue: settings.lineWidth,
      min: 1,
      max: 4,
    },
    {
      key: 'negativeFillColor',
      label: t('Stop color'),
      type: 'color',
      defaultValue: settings.negativeFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'positiveFillColor',
      label: t('Target color'),
      type: 'color',
      defaultValue: settings.positiveFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'showPriceLabels',
      label: t('Price labels'),
      type: 'boolean',
      defaultValue: settings.showPriceLabels,
    },
    {
      key: 'alwaysShowStats',
      label: t('Always show stats'),
      type: 'boolean',
      defaultValue: settings.alwaysShowStats,
    },
  ];

  return [
    {
      key: 'inputs',
      label: t('Arguments'),
      fields: inputFields,
    },
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
  ];
}








import { round } from 'lodash-es';
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,
  getPercentPrecision,
} from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { SliderPaneView } from './paneView';
import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
  SliderPositionArguments,
  SliderPositionSettings,
  SliderPositionStyle,
} 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';
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;
  settings: SliderPositionSettings;
}

interface PositionMetrics {
  qty: number;
  targetAmount: number;
  stopAmount: number;
  riskRewardRatio: number;
  openPnl: number;
}

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 {
  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 MIN_DISTANCE = 0.00000001;
const DEFAULT_RISK_REWARD_RATIO = 1;

/**
 * В текущем moex-chart стоимость одного пункта
 * отдельно не передаётся из metadata инструмента.
 */
const POINT_VALUE = 1;

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

  protected settings: SliderPositionSettings =
    createDefaultSettings();

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

  protected mode: SliderMode = 'idle';

  private readonly 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 readonly clickHandler:
    MouseEventHandler<Time>;

  private readonly paneView:
    SliderPaneView;

  private readonly timeAxisPaneView:
    CustomTimeAxisPaneView;

  private readonly priceAxisPaneView:
    CustomPriceAxisPaneView;

  private readonly startTimeAxisView:
    CustomTimeAxisView;

  private readonly endTimeAxisView:
    CustomTimeAxisView;

  private readonly targetPriceAxisView:
    CustomPriceAxisView;

  private readonly entryPriceAxisView:
    CustomPriceAxisView;

  private readonly 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.settings.accountSize,
      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;

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

    if (
      typeof next.amount === 'number' &&
      next.amount >= 0
    ) {
      this.settings.accountSize =
        next.amount;
    }

    this.render();
  }

  public getSettings(): SettingsValues {
    return {
      ...this.settings,
      ...this.getPositionArguments(),
    };
  }

  public updateSettings(
    values: SettingsValues,
  ): void {
    const {
      entryPrice,
      targetPrice,
      stopPrice,
      ...settings
    } = values as Partial<
      SliderPositionSettings &
        SliderPositionArguments
    >;

    this.applyPositionArguments({
      entryPrice,
      targetPrice,
      stopPrice,
    });

    super.updateSettings(
      settings as SettingsValues,
    );
  }

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

  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 selectedPrice =
      this.getPriceAtTime(
        this.endTime,
      );

    const metrics =
      this.getPositionMetrics(
        geometry,
        selectedPrice,
      );

    const { colors } =
      getThemeStore();

    return {
      ...geometry,

      targetText:
        this.getTargetText(
          geometry,
          metrics.targetAmount,
        ),

      centerText:
        this.getCenterText(
          metrics,
        ),

      stopText:
        this.getStopText(
          geometry,
          metrics.stopAmount,
        ),

      centerBoxColor:
        metrics.openPnl >= 0
          ? colors.chartCandleUp
          : colors.chartCandleDown,

      targetLabelDirection:
        this.side === 'long'
          ? 'up'
          : 'down',

      stopLabelDirection:
        this.side === 'long'
          ? 'down'
          : 'up',

      showLabels:
        this.settings.alwaysShowStats ||
        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 (
      !this.settings.showPriceLabels
    ) {
      return 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 *
            DEFAULT_RISK_REWARD_RATIO,
      );

    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 minPrice =
      this.side === 'long'
        ? snapshot.stopPrice +
          MIN_DISTANCE
        : snapshot.targetPrice;

    const maxPrice =
      this.side === 'long'
        ? snapshot.targetPrice
        : snapshot.stopPrice -
          MIN_DISTANCE;

    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 normalizeStop(
    entryPrice: number,
    rawPrice: number,
  ): number {
    return this.side === 'long'
      ? Math.min(
          rawPrice,
          entryPrice - MIN_DISTANCE,
        )
      : Math.max(
          rawPrice,
          entryPrice + MIN_DISTANCE,
        );
  }

  private normalizeTarget(
    entryPrice: number,
    rawPrice: number,
  ): number {
    return this.side === 'long'
      ? Math.max(
          rawPrice,
          entryPrice,
        )
      : Math.min(
          rawPrice,
          entryPrice,
        );
  }

  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 distance =
      Math.min(
        stopDistance,
        targetDistance /
          DEFAULT_RISK_REWARD_RATIO,
      );

    return distance >=
      MIN_DISTANCE
      ? distance
      : null;
  }

  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 getCurrentRiskRewardRatio():
    number {
    if (
      this.entryPrice === null ||
      this.stopPrice === null ||
      this.targetPrice === null
    ) {
      return DEFAULT_RISK_REWARD_RATIO;
    }

    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 getPositionArguments():
    SliderPositionArguments {
    const entryPrice =
      this.entryPrice ?? 0;

    return {
      entryPrice,
      targetPrice:
        this.targetPrice ??
        entryPrice,
      stopPrice:
        this.stopPrice ??
        entryPrice,
    };
  }

  private applyPositionArguments(
    values:
      Partial<SliderPositionArguments>,
  ): void {
    if (
      typeof values.entryPrice ===
      'number'
    ) {
      this.entryPrice =
        values.entryPrice;
    }

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

    const entryPrice =
      this.entryPrice;

    if (
      typeof values.targetPrice ===
      'number'
    ) {
      this.targetPrice =
        this.normalizeTarget(
          entryPrice,
          values.targetPrice,
        );
    } else if (
      this.targetPrice !== null
    ) {
      this.targetPrice =
        this.normalizeTarget(
          entryPrice,
          this.targetPrice,
        );
    }

    if (
      typeof values.stopPrice ===
      'number'
    ) {
      this.stopPrice =
        this.normalizeStop(
          entryPrice,
          values.stopPrice,
        );
    } else if (
      this.stopPrice !== null
    ) {
      this.stopPrice =
        this.normalizeStop(
          entryPrice,
          this.stopPrice,
        );
    }
  }

  private getPositionMetrics(
    geometry: SliderGeometry,
    selectedPrice: number | null,
  ): PositionMetrics {
    const riskDistance =
      Math.abs(
        geometry.entryPrice -
          geometry.stopPrice,
      );

    const rewardDistance =
      Math.abs(
        geometry.targetPrice -
          geometry.entryPrice,
      );

    const {
      accountSize,
      risk,
      lotSize,
      leverage,
      quantityPrecision,
    } = this.settings;

    if (
      riskDistance <= MIN_DISTANCE ||
      geometry.entryPrice <=
        MIN_DISTANCE
    ) {
      return {
        qty: 0,
        targetAmount:
          accountSize,
        stopAmount:
          accountSize,
        riskRewardRatio: 0,
        openPnl:
          this.getOpenPnl(
            geometry.entryPrice,
            selectedPrice,
          ),
      };
    }

    const riskSize =
      (risk / 100) *
      accountSize;

    const qtyRisk =
      riskSize /
      (
        riskDistance *
        POINT_VALUE
      ) /
      lotSize;

    const qtyLeverage =
      (
        (
          accountSize *
          leverage
        ) /
        geometry.entryPrice
      ) *
      POINT_VALUE /
      lotSize;

    const qty =
      round(
        Math.min(
          qtyRisk,
          qtyLeverage,
        ),
        quantityPrecision,
      );

    const multiplier =
      POINT_VALUE *
      lotSize;

    return {
      qty,

      targetAmount:
        accountSize +
        rewardDistance *
          qty *
          multiplier,

      stopAmount:
        accountSize -
        riskDistance *
          qty *
          multiplier,

      riskRewardRatio:
        rewardDistance /
        riskDistance,

      openPnl:
        this.getOpenPnl(
          geometry.entryPrice,
          selectedPrice,
        ),
    };
  }

  private getOpenPnl(
    entryPrice: number,
    selectedPrice: number | null,
  ): number {
    if (selectedPrice === null) {
      return 0;
    }

    return this.side === 'long'
      ? selectedPrice -
          entryPrice
      : entryPrice -
          selectedPrice;
  }

  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 formattedDiff =
      formatPrice(diff) ?? '0';

    const formattedAmount =
      formatPrice(
        amount,
        2,
      ) ?? '0';

    return (
      `${t('Target')}: ` +
      `${formattedDiff} ` +
      `(${formatPercent(percent)}), ` +
      `${t('Amount')}: ` +
      formattedAmount
    );
  }

  private getCenterText(
    metrics: PositionMetrics,
  ): string {
    const formattedQty =
      formatPrice(
        metrics.qty,
        this.settings
          .quantityPrecision,
      ) ?? '0';

    const formattedRatio =
      formatPrice(
        metrics.riskRewardRatio,
        getPercentPrecision(),
      ) ?? '0';

    return [
      `${t('Open P&L')}: ` +
        `${formatSignedNumber(
          metrics.openPnl,
        )}, ` +
        `${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 formattedDiff =
      formatPrice(diff) ?? '0';

    const formattedAmount =
      formatPrice(
        amount,
        2,
      ) ?? '0';

    return (
      `${t('Stop')}: ` +
      `${formattedDiff} ` +
      `(${formatPercent(percent)}), ` +
      `${t('Amount')}: ` +
      formattedAmount
    );
  }

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

    if (!geometry) {
      return null;
    }

    if (
      Math.abs(
        point.x -
          geometry.startX,
      ) <= HIT_TOLERANCE
    ) {
      const entryDistance =
        Math.abs(
          point.y -
            geometry.entryY,
        );

      if (entryDistance < 1) {
        return 'entry';
      }

      const pointerSide =
        Math.sign(
          point.y -
            geometry.entryY,
        );

      const targetSide =
        Math.sign(
          geometry.targetY -
            geometry.entryY,
        ) ||
        -Math.sign(
          geometry.stopY -
            geometry.entryY,
        );

      const stopSide =
        Math.sign(
          geometry.stopY -
            geometry.entryY,
        ) ||
        -Math.sign(
          geometry.targetY -
            geometry.entryY,
        );

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

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

      if (
        entryDistance <=
        HIT_TOLERANCE
      ) {
        return 'entry';
      }
    }

    const handle =
      this.getDrawingHandleAtPoint(
        point,
      );

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













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

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

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

const UI = {
  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,
          );

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

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

        context.strokeStyle =
          data.lineColor;

        drawHorizontalLine(
          context,
          left,
          right,
          targetY,
        );

        drawHorizontalLine(
          context,
          left,
          right,
          entryY,
        );

        drawHorizontalLine(
          context,
          left,
          right,
          stopY,
        );

        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,
): number {
  const offset =
    labelOffset +
    boxHeight / 2;

  return direction === 'up'
    ? coordinate - offset
    : coordinate + offset;
}

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:
    SliderPositionStyle,
  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(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 x =
    centerX -
    width / 2;

  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],
      centerX,
      centerY,
    );

    context.restore();

    return;
  }

  const textBlockHeight =
    lines.length *
    lineHeight;

  const firstLineCenterY =
    centerY -
    textBlockHeight / 2 +
    lineHeight / 2;

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

  context.restore();
}

function getTextBoxHeight(
  text: string,
  textStyle:
    SliderPositionStyle,
  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(
  fontSize: number,
): string {
  return `${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,
    precision = 4,
  ) => {
    const factor =
      10 ** precision;

    return String(
      Math.round(
        value * factor,
      ) / factor,
    );
  },

  formatPercent: (
    value: number,
  ) => {
    const factor = 100;

    return (
      `${
        Math.round(
          value * factor,
        ) / factor
      }%`
    );
  },

  formatSignedNumber: (
    value: number,
  ) => String(value),

  getPercentPrecision:
    () => 2,
}));

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,

    settings: {
      ...createDefaultSettings(),

      accountSize: 1000,
      risk: 25,
      lotSize: 1,
      leverage: 5,

      quantityPrecision: 3,
    },
  });
}

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(
      'при риске 25% и RR 1 считает 1000 -> 1250/750',
      () => {
        const { slider } =
          createSlider();

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 110,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 25,
            lotSize: 1,
            leverage: 5,

            quantityPrecision: 3,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 10 (10%), Amount: 1250',

          centerText:
            'Open P&L: 10, Qty: 25\n' +
            'Risk/Reward Ratio: 1',

          stopText:
            'Stop: 10 (10%), Amount: 750',
        });
      },
    );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 110,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 25,
            lotSize: 1,
            leverage: 1,

            quantityPrecision: 3,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 10 (10%), Amount: 1100',

          centerText:
            'Open P&L: 10, Qty: 10\n' +
            'Risk/Reward Ratio: 1',

          stopText:
            'Stop: 10 (10%), Amount: 900',
        });
      },
    );

    it(
      'всегда трактует Risk как процент от размера счёта',
      () => {
        const { slider } =
          createSlider();

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 120,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 2,
            lotSize: 1,
            leverage: 10,

            quantityPrecision: 3,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 20 (20%), Amount: 1040',

          centerText:
            'Open P&L: 10, Qty: 2\n' +
            'Risk/Reward Ratio: 2',

          stopText:
            'Stop: 10 (10%), Amount: 980',
        });
      },
    );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 110,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 25,
            lotSize: 5,
            leverage: 5,

            quantityPrecision: 3,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 10 (10%), Amount: 1250',

          centerText:
            'Open P&L: 10, Qty: 5\n' +
            'Risk/Reward Ratio: 1',

          stopText:
            'Stop: 10 (10%), Amount: 750',
        });
      },
    );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 93,
          targetPrice: 114,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 10,
            lotSize: 1,
            leverage: 10,

            quantityPrecision: 2,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 14 (14%), Amount: 1200.06',

          centerText:
            'Open P&L: 10, Qty: 14.29\n' +
            'Risk/Reward Ratio: 2',

          stopText:
            'Stop: 7 (7%), Amount: 899.97',
        });
      },
    );

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

        setReady(slider);

        expect(
          slider
            .getRenderData()
            ?.centerText,
        ).toContain(
          'Qty: 25',
        );

        slider.setState({
          targetPrice: 140,
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 40 (40%), Amount: 2000',

          centerText:
            'Open P&L: 10, Qty: 25\n' +
            'Risk/Reward Ratio: 4',

          stopText:
            'Stop: 10 (10%), Amount: 750',
        });
      },
    );

    it(
      'изменение Stop пересчитывает Qty',
      () => {
        const { slider } =
          createSlider();

        setReady(slider);

        slider.setState({
          stopPrice: 95,
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 20 (20%), Amount: 2000',

          centerText:
            'Open P&L: 10, Qty: 50\n' +
            'Risk/Reward Ratio: 4',

          stopText:
            'Stop: 5 (5%), Amount: 750',
        });
      },
    );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 110,
          targetPrice: 80,

          settings: {
            ...createDefaultSettings(),

            accountSize: 1000,
            risk: 25,
            lotSize: 1,
            leverage: 5,

            quantityPrecision: 3,
          },
        });

        expect(
          slider.getRenderData(),
        ).toMatchObject({
          targetText:
            'Target: 20 (20%), Amount: 1500',

          centerText:
            'Open P&L: -10, Qty: 25\n' +
            'Risk/Reward Ratio: 2',

          stopText:
            'Stop: 10 (10%), Amount: 750',

          targetLabelDirection:
            'down',

          stopLabelDirection:
            'up',
        });
      },
    );

    it(
      'разрешает Risk/Reward Ratio = 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,
        });

        expect(
          slider
            .getRenderData()
            ?.centerText,
        ).toContain(
          'Risk/Reward Ratio: 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 имеет приоритет при точном совпадении с Target',
      () => {
        const {
          slider,
          container,
          priceToCoordinate,
        } = createSlider(
          'long',
          true,
        );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 101,
        });

        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,
        );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 99,
          targetPrice: 120,
        });

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

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

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

    it(
      'позволяет вытянуть Target из overlap',
      () => {
        const {
          slider,
          container,
          priceToCoordinate,
        } = createSlider(
          'long',
          true,
        );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 101,
        });

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

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

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

    it(
      'позволяет вытянуть Stop из overlap',
      () => {
        const {
          slider,
          container,
          priceToCoordinate,
        } = createSlider(
          'long',
          true,
        );

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 99,
          targetPrice: 120,
        });

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

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

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

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 90,
          targetPrice: 101,
        });

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

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

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

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

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 99,
          targetPrice: 120,
        });

        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(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,
        ).toBeCloseTo(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(
      'для short не даёт Stop пересечь Entry и разрешает RR = 0',
      () => {
        const {
          slider,
          container,
        } = createSlider(
          'short',
          true,
        );

        slider.setState({
          mode: 'ready',

          startTime:
            10 as Time,

          endTime:
            100 as Time,

          entryPrice: 100,
          stopPrice: 110,
          targetPrice: 80,
        });

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

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

        expect(
          slider
            .getState()
            .targetPrice,
        ).toBe(100);

        slider.pointerUp(
          createPointerEvent(
            container,
            10,
            100,
          ),
        );

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

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

        expect(
          slider
            .getState()
            .stopPrice,
        ).toBeCloseTo(100);
      },
    );

    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 }) =>
                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 }) =>
                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(
      'поддерживает старое amount как alias для accountSize',
      () => {
        const { slider } =
          createSlider();

        slider.setState({
          amount: 2000,
        });

        expect(
          slider.getState(),
        ).toMatchObject({
          amount: 2000,
        });

        expect(
          slider.getSettings(),
        ).toMatchObject({
          accountSize: 2000,
        });
      },
    );

    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 для 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',

            lineWidth: 1,

            positiveFillColor:
              '#0f03',

            negativeFillColor:
              '#f003',

            textColor:
              '#fff',

            fontSize: 10,

            showPriceLabels:
              true,

            alwaysShowStats:
              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({
          accountSize: 1000,
          risk: 25,
          lotSize: 1,
          leverage: 10000,

          quantityPrecision: 3,

          lineColor:
            '#999999',

          lineWidth: 1,

          positiveFillColor:
            '#00aa0044',

          negativeFillColor:
            '#aa000044',

          textColor:
            '#ffffff',

          fontSize: 10,

          showPriceLabels:
            true,

          alwaysShowStats:
            false,
        });
      },
    );

    it(
      'создаёт только Arguments и Style tabs',
      () => {
        const tabs =
          getSliderPositionSettingsTabs(
            createDefaultSettings(),
            {
              entryPrice: 100,
              targetPrice: 120,
              stopPrice: 90,
            },
          );

        expect(
          tabs.map(
            ({ key }) => key,
          ),
        ).toEqual([
          'inputs',
          'style',
        ]);
      },
    );

    it(
      'обновляет Entry, Target и Stop через настройки',
      () => {
        const { slider } =
          createSlider();

        setReady(slider);

        slider.updateSettings({
          entryPrice: 105,
          targetPrice: 110,
          stopPrice: 102,
        });

        expect(
          slider.getState(),
        ).toMatchObject({
          entryPrice: 105,
          targetPrice: 110,
          stopPrice: 102,
        });
      },
    );

    it(
      'скрывает price labels',
      () => {
        const { slider } =
          createSlider(
            'long',
            true,
          );

        setReady(slider);

        slider.updateSettings({
          showPriceLabels:
            false,
        });

        expect(
          slider.priceLabel(
            'target',
          ),
        ).toBeNull();

        expect(
          slider.priceLabel(
            'entry',
          ),
        ).toBeNull();

        expect(
          slider.priceLabel(
            'stop',
          ),
        ).toBeNull();
      },
    );

    it(
      'показывает stats без selection',
      () => {
        const { slider } =
          createSlider();

        setReady(slider);

        expect(
          slider
            .getRenderData()
            ?.showLabels,
        ).toBe(false);

        slider.updateSettings({
          alwaysShowStats:
            true,
        });

        expect(
          slider
            .getRenderData()
            ?.showLabels,
        ).toBe(true);
      },
    );
  },
);