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


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

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

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 DEFAULT_RISK_REWARD_RATIO = 1;

export class SliderPosition
  extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
  implements ISeriesDrawing
{
  protected settings: SliderPositionSettings = createDefaultSettings();
  protected mode: SliderMode = 'idle';

  private readonly side: SliderSide;
  private readonly removeSelf?: () => void;
  private readonly openSettings?: () => void;
  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;

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

  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;

  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>;
    const {
      hidden = this.hidden,
      mode = this.mode,
      startTime = this.startTime,
      endTime = this.endTime,
      entryPrice = this.entryPrice,
    } = next;

    this.hidden = hidden;
    this.mode = mode;
    this.startTime = startTime;
    this.endTime = endTime;
    this.entryPrice = entryPrice;

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

    const targetPrice = this.resolveTargetPrice(next, entryPrice);

    if (targetPrice !== undefined) {
      this.targetPrice = targetPrice;
    }

    if (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 metrics = this.getPositionMetrics(
      geometry,
      this.getPriceAtTime(this.endTime),
    );
    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.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';
    } else 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;
    } else 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?.();
  };

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

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

  private resolveTargetPrice(
    state: Partial<SliderPositionState>,
    entryPrice: number | null,
  ): number | null | undefined {
    if (state.targetPrice !== undefined) {
      if (state.targetPrice === null || entryPrice === null) {
        return state.targetPrice;
      }

      return this.normalizeTarget(entryPrice, state.targetPrice);
    }

    const ratio = state.riskRewardRatio;

    if (
      entryPrice === null ||
      this.stopPrice === null ||
      typeof ratio !== 'number' ||
      ratio < 0
    ) {
      return undefined;
    }

    const risk = Math.abs(entryPrice - this.stopPrice);
    const direction = this.side === 'long' ? 1 : -1;

    return this.normalizeTarget(
      entryPrice,
      entryPrice + risk * ratio * direction,
    );
  }

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

  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 minDistance = getPricePrecisionStep();

    const minPrice =
      this.side === 'long'
        ? snapshot.stopPrice + minDistance
        : snapshot.targetPrice;

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

    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 ||
      !this.dragStartPoint
    ) {
      return;
    }

    const timeOffset = point.x - this.dragStartPoint.x;
    const priceOffset = getPriceDeltaFromCoordinates(
      this.series,
      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 = getPriceRangeInContainer(this.series, this.container);

    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, price: number): number {
    const minDistance = getPricePrecisionStep();

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

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

  private clampPriceToRange(price: number): number {
    const range = getPriceRangeInContainer(this.series, this.container);

    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 >= getPricePrecisionStep() ? distance : null;
  }

  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 === 0) {
      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;
      } else if ('value' in item && typeof item.value === 'number') {
        lastPrice = item.value;
      }
    }

    return lastPrice;
  }

  protected getGeometry(): SliderGeometry | null {
    if (
      this.startTime === null ||
      this.endTime === null ||
      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;
    }

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

    if (typeof values.stopPrice === 'number') {
      this.stopPrice = this.normalizeStop(
        this.entryPrice,
        values.stopPrice,
      );
    } else if (this.stopPrice !== null) {
      this.stopPrice = this.normalizeStop(
        this.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 === 0 || geometry.entryPrice <= 0) {
      return {
        qty: 0,
        targetAmount: accountSize,
        stopAmount: accountSize,
        riskRewardRatio: 0,
        openPnl: this.getOpenPnl(
          geometry.entryPrice,
          selectedPrice,
        ),
      };
    }

    const riskSize = (risk / 100) * accountSize;
    const qtyRisk = riskSize / riskDistance / lotSize;
    const qtyLeverage =
      (accountSize * leverage) / geometry.entryPrice / lotSize;
    const qty = round(
      Math.min(qtyRisk, qtyLeverage),
      quantityPrecision,
    );
    const multiplier = 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;
    }

    const startHandle = this.getStartPriceHandleTarget(
      point,
      geometry,
    );

    if (startHandle) {
      return startHandle;
    }

    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 getStartPriceHandleTarget(
    point: Point,
    geometry: SliderGeometry,
  ): SliderHandleId | null {
    if (
      Math.abs(point.x - geometry.startX) >
      HIT_TOLERANCE
    ) {
      return null;
    }

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

    if (targetSide === 0) {
      return entryDistance <= HIT_TOLERANCE
        ? 'entry'
        : null;
    }

    const isTargetSide = pointerSide === targetSide;
    const handleY = isTargetSide
      ? geometry.targetY
      : geometry.stopY;

    if (
      Math.abs(point.y - handleY) <=
      HIT_TOLERANCE
    ) {
      return isTargetSide ? 'target' : 'stop';
    }

    return entryDistance <= HIT_TOLERANCE
      ? 'entry'
      : 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 { 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,
  getPricePrecisionStep: () => 0.01,
}));

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\nRisk/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\nRisk/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\nRisk/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\nRisk/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\nRisk/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\nRisk/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\nRisk/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\nRisk/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('использует минимальный шаг цены только для Stop', () => {
    const { slider } = createSlider();

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

    expect(
      slider.getState(),
    ).toMatchObject({
      entryPrice: 100,
      stopPrice: 99.99,
      targetPrice: 100,
      riskRewardRatio: 0,
    });
  });

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

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

    expect(
      slider.getState(),
    ).toMatchObject({
      entryPrice: 100,
      stopPrice: 100.01,
      targetPrice: 100,
      riskRewardRatio: 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('Entry не пересекает Stop и Target', () => {
    const {
      slider,
      container,
    } = createSlider('long', true);

    setReady(slider);

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

    expect(
      slider.getState().entryPrice,
    ).toBe(120);
  });

  it('оставляет минимальный шаг между Stop и Entry', () => {
    const {
      slider,
      container,
    } = createSlider('long', true);

    setReady(slider);

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

    expect(
      slider.getState().stopPrice,
    ).toBe(99.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(100);
  });

  it('для short оставляет минимальный шаг между Stop и Entry', () => {
    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,
        110,
      ),
    );
    slider.pointerMove(
      createPointerEvent(
        container,
        10,
        100,
      ),
    );

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

  it('для short Target может совпасть с Entry', () => {
    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);
  });

  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('возвращает 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('time labels показываются только у selected drawing', () => {
    const { slider } =
      createSlider('long', true);

    setReady(slider);

    expect(
      slider.timeLabel('start'),
    ).toMatchObject({
      coordinate: 10,
      text: 'date:10',
    });

    expect(
      slider.timeLabel('end'),
    ).toMatchObject({
      coordinate: 100,
      text: 'date:100',
    });
  });

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

  it('поддерживает legacy amount как accountSize', () => {
    const { slider } = createSlider();

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

    expect(
      slider.getState().settings
        .accountSize,
    ).toBe(2500);
  });
});

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: '#00aa00',
      targetLabelDirection: 'up',
      stopLabelDirection: 'down',
      showLabels: true,
      lineColor: '#ffffff',
      lineWidth: 1,
      positiveFillColor: '#00ff0033',
      negativeFillColor: '#ff000033',
      textColor: '#ffffff',
      fontSize: 10,
    };

    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,
    ).toHaveBeenCalledTimes(3);
  });
});

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 без boolean-настроек', () => {
    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,
    });
  });

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

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









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,
  );
  const referenceY =
    targetSpace < stopSpace
      ? stopY
      : targetY;
  const direction =
    referenceY < entryY ? -1 : 1;

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