Загрузка данных
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,
);
}