Загрузка данных
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface SliderPositionStyle {
lineColor: string;
lineWidth: number;
positiveFillColor: string;
negativeFillColor: string;
textColor: string;
fontSize: number;
showPriceLabels: boolean;
alwaysShowStats: boolean;
}
export interface SliderPositionCalculationSettings {
accountSize: number;
risk: number;
lotSize: number;
leverage: number;
quantityPrecision: number;
}
export interface SliderPositionArguments {
entryPrice: number;
targetPrice: number;
stopPrice: number;
}
export type SliderPositionSettings =
SettingsValues &
SliderPositionStyle &
SliderPositionCalculationSettings;
export function createDefaultSettings(): SliderPositionSettings {
const { colors } = getThemeStore();
return {
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 10000,
quantityPrecision: 3,
lineColor: colors.chartCrosshairLine,
lineWidth: 1,
positiveFillColor: colors.sliderPositiveFill,
negativeFillColor: colors.sliderNegativeFill,
textColor: colors.chartPriceLineText,
fontSize: 10,
showPriceLabels: true,
alwaysShowStats: false,
};
}
export function getSliderPositionSettingsTabs(
settings: SliderPositionSettings,
position: SliderPositionArguments,
): SettingsTab[] {
const inputFields: SettingField[] = [
{
key: 'accountSize',
label: t('Account size'),
type: 'number',
defaultValue: settings.accountSize,
min: 0,
},
{
key: 'lotSize',
label: t('Lot size'),
type: 'number',
defaultValue: settings.lotSize,
min: 0.00000001,
},
{
key: 'risk',
label: t('Risk, %'),
type: 'number',
defaultValue: settings.risk,
min: 0,
},
{
key: 'entryPrice',
label: t('Entry price'),
type: 'number',
defaultValue: position.entryPrice,
},
{
key: 'leverage',
label: t('Leverage'),
type: 'number',
defaultValue: settings.leverage,
min: 1,
},
{
key: 'targetPrice',
label: t('Profit price'),
type: 'number',
defaultValue: position.targetPrice,
},
{
key: 'stopPrice',
label: t('Stop price'),
type: 'number',
defaultValue: position.stopPrice,
},
{
key: 'quantityPrecision',
label: t('QTY precision'),
type: 'number',
defaultValue: settings.quantityPrecision,
min: 0,
max: 16,
},
];
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line'),
type: 'color',
defaultValue: settings.lineColor,
},
{
key: 'lineWidth',
label: t('Line width'),
type: 'number',
defaultValue: settings.lineWidth,
min: 1,
max: 4,
},
{
key: 'negativeFillColor',
label: t('Stop color'),
type: 'color',
defaultValue: settings.negativeFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'positiveFillColor',
label: t('Target color'),
type: 'color',
defaultValue: settings.positiveFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'textColor',
label: t('Text'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'showPriceLabels',
label: t('Price labels'),
type: 'boolean',
defaultValue: settings.showPriceLabels,
},
{
key: 'alwaysShowStats',
label: t('Always show stats'),
type: 'boolean',
defaultValue: settings.alwaysShowStats,
},
];
return [
{
key: 'inputs',
label: t('Arguments'),
fields: inputFields,
},
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
];
}
import { round } from 'lodash-es';
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import {
formatPercent,
formatPrice,
formatSignedNumber,
getPercentPrecision,
} from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
SliderPositionArguments,
SliderPositionSettings,
SliderPositionStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type {
AxisLabel,
AxisSegment,
Bounds,
Point,
} from '@core/Drawings/types';
import type {
BaseDrawingParams,
ISeriesDrawing,
} from '@src/core/Drawings/SeriesDrawingBase';
import type {
ChartOptionsModel,
SettingsTab,
SettingsValues,
} from '@src/types';
import type {
IPrimitivePaneView,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget =
| 'body'
| 'entry'
| 'target'
| 'stop'
| 'end'
| null;
type SliderHandleId = Exclude<
DragTarget,
'body' | null
>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind =
| 'target'
| 'entry'
| 'stop';
interface SliderPositionParams extends BaseDrawingParams {
side: SliderSide;
resetTriggers?: Observable<unknown>[];
}
interface SliderPositionState {
hidden: boolean;
mode: SliderMode;
startTime: Time | null;
endTime: Time | null;
entryPrice: number | null;
stopPrice: number | null;
targetPrice: number | null;
riskRewardRatio: number;
amount: number;
settings: SliderPositionSettings;
}
interface PositionMetrics {
qty: number;
targetAmount: number;
stopAmount: number;
riskRewardRatio: number;
openPnl: number;
}
interface SliderGeometry {
startX: number;
endX: number;
leftX: number;
rightX: number;
entryY: number;
stopY: number;
targetY: number;
entryPrice: number;
stopPrice: number;
targetPrice: number;
profitTop: number;
profitBottom: number;
lossTop: number;
lossBottom: number;
}
export interface SliderRenderData
extends SliderGeometry,
SliderPositionStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
targetLabelDirection: 'up' | 'down';
stopLabelDirection: 'up' | 'down';
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const INITIAL_ZONE_OFFSET_PX = 60;
const MIN_DISTANCE = 0.00000001;
const DEFAULT_RISK_REWARD_RATIO = 1;
/**
* В текущем moex-chart стоимость одного пункта
* отдельно не передаётся из metadata инструмента.
*/
const POINT_VALUE = 1;
export class SliderPosition
extends SeriesDrawingBase<
SliderPositionSettings,
SliderHandleId
>
implements ISeriesDrawing
{
private readonly removeSelf?: () => void;
private readonly openSettings?: () => void;
protected settings: SliderPositionSettings =
createDefaultSettings();
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: SliderMode = 'idle';
private readonly side: SliderSide;
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot:
| SliderPositionState
| null = null;
private readonly clickHandler:
MouseEventHandler<Time>;
private readonly paneView:
SliderPaneView;
private readonly timeAxisPaneView:
CustomTimeAxisPaneView;
private readonly priceAxisPaneView:
CustomPriceAxisPaneView;
private readonly startTimeAxisView:
CustomTimeAxisView;
private readonly endTimeAxisView:
CustomTimeAxisView;
private readonly targetPriceAxisView:
CustomPriceAxisView;
private readonly entryPriceAxisView:
CustomPriceAxisView;
private readonly stopPriceAxisView:
CustomPriceAxisView;
constructor({
chart,
series,
side,
container,
interaction,
formatObservable,
resetTriggers = [],
removeSelf,
openSettings,
initialEvent,
}: SliderPositionParams) {
super({
chart,
series,
container,
interaction,
});
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) =>
this.handleChartClick(params);
this.paneView =
new SliderPaneView(this);
this.timeAxisPaneView =
new CustomTimeAxisPaneView({
getAxisSegments: () =>
this.getTimeAxisSegments(),
zOrder: 'normal',
});
this.priceAxisPaneView =
new CustomPriceAxisPaneView({
getAxisSegments: () =>
this.getPriceAxisSegments(),
zOrder: 'normal',
});
this.startTimeAxisView =
new CustomTimeAxisView({
getAxisLabel: (kind) =>
this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView =
new CustomTimeAxisView({
getAxisLabel: (kind) =>
this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.targetPriceAxisView =
new CustomPriceAxisView({
getAxisLabel: (kind) =>
this.getPriceAxisLabel(kind),
labelKind: 'target',
});
this.entryPriceAxisView =
new CustomPriceAxisView({
getAxisLabel: (kind) =>
this.getPriceAxisLabel(kind),
labelKind: 'entry',
});
this.stopPriceAxisView =
new CustomPriceAxisView({
getAxisLabel: (kind) =>
this.getPriceAxisLabel(kind),
labelKind: 'stop',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger
.pipe(skip(1))
.subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
if (initialEvent) {
this.handleChartClick(initialEvent);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio:
this.getCurrentRiskRewardRatio(),
amount: this.settings.accountSize,
settings: {
...this.settings,
},
};
}
public setState(state: unknown): void {
if (
!state ||
typeof state !== 'object'
) {
return;
}
const next =
state as Partial<SliderPositionState>;
this.hidden =
next.hidden ?? this.hidden;
this.mode =
next.mode ?? this.mode;
if (next.startTime !== undefined) {
this.startTime = next.startTime;
}
if (next.endTime !== undefined) {
this.endTime = next.endTime;
}
if (next.entryPrice !== undefined) {
this.entryPrice =
next.entryPrice;
}
if (next.stopPrice !== undefined) {
this.stopPrice =
next.stopPrice === null ||
this.entryPrice === null
? next.stopPrice
: this.normalizeStop(
this.entryPrice,
next.stopPrice,
);
}
if (
next.targetPrice !== undefined
) {
this.targetPrice =
next.targetPrice === null ||
this.entryPrice === null
? next.targetPrice
: this.normalizeTarget(
this.entryPrice,
next.targetPrice,
);
} else if (
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio ===
'number' &&
next.riskRewardRatio >= 0
) {
const risk = Math.abs(
this.entryPrice -
this.stopPrice,
);
const direction =
this.side === 'long'
? 1
: -1;
const targetPrice =
this.entryPrice +
risk *
next.riskRewardRatio *
direction;
this.targetPrice =
this.normalizeTarget(
this.entryPrice,
targetPrice,
);
}
if (
'settings' in next &&
next.settings
) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
if (
typeof next.amount === 'number' &&
next.amount >= 0
) {
this.settings.accountSize =
next.amount;
}
this.render();
}
public getSettings(): SettingsValues {
return {
...this.settings,
...this.getPositionArguments(),
};
}
public updateSettings(
values: SettingsValues,
): void {
const {
entryPrice,
targetPrice,
stopPrice,
...settings
} = values as Partial<
SliderPositionSettings &
SliderPositionArguments
>;
this.applyPositionArguments({
entryPrice,
targetPrice,
stopPrice,
});
super.updateSettings(
settings as SettingsValues,
);
}
public getSettingsTabs():
SettingsTab[] {
return getSliderPositionSettingsTabs(
this.settings,
this.getPositionArguments(),
);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
]);
}
public paneViews():
readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews():
readonly IPrimitivePaneView[] {
return [
this.timeAxisPaneView,
];
}
public priceAxisPaneViews():
readonly IPrimitivePaneView[] {
return [
this.priceAxisPaneView,
];
}
public timeAxisViews() {
return [
this.startTimeAxisView,
this.endTimeAxisView,
];
}
public priceAxisViews() {
return [
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
];
}
public getRenderData():
SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry =
this.getGeometry();
if (!geometry) {
return null;
}
const selectedPrice =
this.getPriceAtTime(
this.endTime,
);
const metrics =
this.getPositionMetrics(
geometry,
selectedPrice,
);
const { colors } =
getThemeStore();
return {
...geometry,
targetText:
this.getTargetText(
geometry,
metrics.targetAmount,
),
centerText:
this.getCenterText(
metrics,
),
stopText:
this.getStopText(
geometry,
metrics.stopAmount,
),
centerBoxColor:
metrics.openPnl >= 0
? colors.chartCandleUp
: colors.chartCandleDown,
targetLabelDirection:
this.side === 'long'
? 'up'
: 'down',
stopLabelDirection:
this.side === 'long'
? 'down'
: 'up',
showLabels:
this.settings.alwaysShowStats ||
this.isSelected(),
...this.settings,
};
}
public getTimeBounds(): {
left: number;
right: number;
} | null {
const start =
this.getTimeCoordinate(
'start',
);
const end =
this.getTimeCoordinate(
'end',
);
if (
start === null ||
end === null
) {
return null;
}
return {
left: Math.min(start, end),
right: Math.max(start, end),
};
}
public getTimeCoordinate(
kind: TimeLabelKind,
): number | null {
const time =
kind === 'start'
? this.startTime
: this.endTime;
if (time === null) {
return null;
}
const coordinate =
getXCoordinateFromTime(
this.chart,
time,
this.series,
);
return coordinate === null
? null
: Number(coordinate);
}
public getTimeText(
kind: TimeLabelKind,
): string {
const time =
kind === 'start'
? this.startTime
: this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceCoordinate(
kind: PriceLabelKind,
): number | null {
const geometry =
this.getGeometry();
if (!geometry) {
return null;
}
switch (kind) {
case 'target':
return geometry.targetY;
case 'entry':
return geometry.entryY;
case 'stop':
return geometry.stopY;
default:
return null;
}
}
public getPriceText(
kind: PriceLabelKind,
): string {
const geometry =
this.getGeometry();
if (!geometry) {
return '';
}
switch (kind) {
case 'target':
return (
formatPrice(
geometry.targetPrice,
) ?? ''
);
case 'entry':
return (
formatPrice(
geometry.entryPrice,
) ?? ''
);
case 'stop':
return (
formatPrice(
geometry.stopPrice,
) ?? ''
);
default:
return '';
}
}
protected getHoveredItem(
x: number,
y: number,
): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isSelected()) {
if (
!this.containsPoint(point)
) {
return null;
}
return {
cursorStyle: 'pointer',
externalId:
'slider-position',
zOrder: 'top',
};
}
const dragTarget =
this.getHandleTarget(point);
if (!dragTarget) {
return null;
}
let cursorStyle:
PrimitiveHoveredItem['cursorStyle'] =
'grab';
if (
dragTarget === 'target' ||
dragTarget === 'stop'
) {
cursorStyle = 'ns-resize';
}
if (
dragTarget === 'end'
) {
cursorStyle = 'ew-resize';
}
return {
cursorStyle,
externalId:
'slider-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments():
AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const bounds =
this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } =
getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color:
colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments():
AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
const { colors } =
getThemeStore();
return [
{
from: geometry.profitTop,
to: geometry.profitBottom,
color:
colors.axisMarkerAreaFill,
},
{
from: geometry.lossTop,
to: geometry.lossBottom,
color:
colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.isSelected() ||
(
kind !== 'start' &&
kind !== 'end'
)
) {
return null;
}
const labelKind =
kind as TimeLabelKind;
const coordinate =
this.getTimeCoordinate(
labelKind,
);
const text =
this.getTimeText(
labelKind,
);
if (
coordinate === null ||
!text
) {
return null;
}
const { colors } =
getThemeStore();
return {
coordinate,
text,
textColor:
colors.chartPriceLineText,
backgroundColor:
colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.settings.showPriceLabels
) {
return null;
}
if (
kind !== 'target' &&
kind !== 'entry' &&
kind !== 'stop'
) {
return null;
}
const labelKind =
kind as PriceLabelKind;
const coordinate =
this.getPriceCoordinate(
labelKind,
);
const text =
this.getPriceText(
labelKind,
);
if (
coordinate === null ||
!text
) {
return null;
}
const { colors } =
getThemeStore();
let backgroundColor =
colors.axisMarkerLabelDefaultFill;
if (
labelKind === 'target'
) {
backgroundColor =
colors.axisMarkerLabelPositiveFill;
}
if (
labelKind === 'stop'
) {
backgroundColor =
colors.axisMarkerLabelNegativeFill;
}
return {
coordinate,
text,
textColor:
colors.chartPriceLineText,
backgroundColor,
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
super.bindEvents();
this.chart.subscribeClick(
this.clickHandler,
);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.chart.unsubscribeClick(
this.clickHandler,
);
super.unbindEvents();
}
protected handleDoubleClick =
(
event: MouseEvent,
): void => {
if (
this.hidden ||
this.mode !== 'ready' ||
!this.isSelected()
) {
return;
}
const point =
this.getEventPoint(
event as PointerEvent,
);
if (
!this.containsPoint(point)
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handleChartClick(
params:
MouseEventParams<Time>,
): void {
// todo: привести к общему виду
// дровингов как
// handleChartClick => startDrawing
if (
this.hidden ||
!params.point ||
this.mode !== 'idle'
) {
return;
}
const anchor =
this.createAnchor(params);
if (!anchor) {
return;
}
const distance =
this.getInitialZoneDistance(
anchor.price,
params.point.y,
);
if (distance === null) {
this.mode = 'ready';
this.resolveReady?.();
this.removeSelf?.();
return;
}
const stopDirection =
this.side === 'long'
? -1
: 1;
const targetDirection =
-stopDirection;
this.startTime =
anchor.time;
this.endTime =
this.shiftTime(
anchor.time,
INITIAL_WIDTH_PX,
) ?? anchor.time;
this.entryPrice =
anchor.price;
this.stopPrice =
this.normalizeStop(
anchor.price,
anchor.price +
distance *
stopDirection,
);
this.targetPrice =
this.normalizeTarget(
anchor.price,
anchor.price +
distance *
targetDirection *
DEFAULT_RISK_REWARD_RATIO,
);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
protected handlePointerDown =
(
event: PointerEvent,
): void => {
if (
this.hidden ||
this.mode !== 'ready' ||
event.button !== 0
) {
return;
}
const point =
this.getEventPoint(event);
if (!this.isSelected()) {
if (
!this.containsPoint(point)
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget =
this.getHandleTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget =
dragTarget;
this.dragPointerId =
event.pointerId;
this.dragStartPoint =
point;
this.dragStateSnapshot =
this.getState();
this.mode = 'dragging';
};
protected handlePointerMove =
(
event: PointerEvent,
): void => {
if (
this.mode !==
'dragging' ||
this.dragPointerId !==
event.pointerId ||
!this.dragStateSnapshot
) {
return;
}
event.preventDefault();
this.applyDrag(
this.getEventPoint(event),
);
this.render();
};
protected handlePointerUp =
(
event: PointerEvent,
): void => {
if (
this.mode !==
'dragging' ||
this.dragPointerId !==
event.pointerId
) {
return;
}
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
};
private applyDrag(
point: Point,
): void {
const snapshot =
this.dragStateSnapshot;
if (!snapshot) {
return;
}
switch (
this.activeDragTarget
) {
case 'body':
this.moveWhole(
snapshot,
point,
);
break;
case 'entry':
this.moveEntry(
snapshot,
point,
);
break;
case 'target':
this.moveTarget(
snapshot,
point,
);
break;
case 'stop':
this.moveStop(
snapshot,
point,
);
break;
case 'end':
this.resizeEnd(
snapshot,
point,
);
break;
default:
break;
}
}
private moveEntry(
snapshot:
SliderPositionState,
point: Point,
): void {
if (
snapshot.stopPrice ===
null ||
snapshot.targetPrice ===
null
) {
return;
}
const nextPrice =
getPriceFromYCoordinate(
this.series,
point.y,
);
if (
nextPrice === null
) {
return;
}
const minPrice =
this.side === 'long'
? snapshot.stopPrice +
MIN_DISTANCE
: snapshot.targetPrice;
const maxPrice =
this.side === 'long'
? snapshot.targetPrice
: snapshot.stopPrice -
MIN_DISTANCE;
if (
minPrice > maxPrice
) {
return;
}
this.entryPrice =
Math.max(
minPrice,
Math.min(
nextPrice,
maxPrice,
),
);
}
private moveWhole(
snapshot:
SliderPositionState,
point: Point,
): void {
if (
snapshot.startTime ===
null ||
snapshot.endTime ===
null
) {
return;
}
if (
snapshot.entryPrice ===
null ||
snapshot.stopPrice ===
null ||
snapshot.targetPrice ===
null
) {
return;
}
if (!this.dragStartPoint) {
return;
}
const timeOffset =
point.x -
this.dragStartPoint.x;
const priceOffset =
this.getPriceDelta(
this.dragStartPoint.y,
point.y,
);
const nextStartTime =
this.shiftTime(
snapshot.startTime,
timeOffset,
);
const nextEndTime =
this.shiftTime(
snapshot.endTime,
timeOffset,
);
if (
nextStartTime === null ||
nextEndTime === null
) {
return;
}
let nextEntryPrice =
snapshot.entryPrice +
priceOffset;
let nextStopPrice =
snapshot.stopPrice +
priceOffset;
let nextTargetPrice =
snapshot.targetPrice +
priceOffset;
const range =
this.getPriceScaleRange();
if (range) {
const minValue =
Math.min(
nextEntryPrice,
nextStopPrice,
nextTargetPrice,
);
const maxValue =
Math.max(
nextEntryPrice,
nextStopPrice,
nextTargetPrice,
);
if (
minValue < range.min
) {
const shift =
range.min - minValue;
nextEntryPrice += shift;
nextStopPrice += shift;
nextTargetPrice += shift;
}
if (
maxValue > range.max
) {
const shift =
maxValue - range.max;
nextEntryPrice -= shift;
nextStopPrice -= shift;
nextTargetPrice -= shift;
}
}
this.startTime =
nextStartTime;
this.endTime =
nextEndTime;
this.entryPrice =
nextEntryPrice;
this.stopPrice =
nextStopPrice;
this.targetPrice =
nextTargetPrice;
}
private moveStop(
snapshot:
SliderPositionState,
point: Point,
): void {
if (
snapshot.entryPrice ===
null
) {
return;
}
const nextPrice =
getPriceFromYCoordinate(
this.series,
point.y,
);
if (
nextPrice === null
) {
return;
}
this.stopPrice =
this.normalizeStop(
snapshot.entryPrice,
this.clampPriceToRange(
nextPrice,
),
);
}
private moveTarget(
snapshot:
SliderPositionState,
point: Point,
): void {
if (
snapshot.entryPrice ===
null
) {
return;
}
const nextPrice =
getPriceFromYCoordinate(
this.series,
point.y,
);
if (
nextPrice === null
) {
return;
}
this.targetPrice =
this.normalizeTarget(
snapshot.entryPrice,
this.clampPriceToRange(
nextPrice,
),
);
}
private resizeEnd(
snapshot:
SliderPositionState,
point: Point,
): void {
const nextEndTime =
getTimeFromXCoordinate(
this.chart,
point.x,
);
if (
nextEndTime === null
) {
return;
}
this.startTime =
snapshot.startTime;
this.endTime =
nextEndTime;
}
private createAnchor(
params:
MouseEventParams<Time>,
): {
time: Time;
price: number;
} | null {
if (
!params.point ||
params.time === undefined
) {
return null;
}
const price =
getPriceFromYCoordinate(
this.series,
params.point.y,
);
if (price === null) {
return null;
}
return {
time: params.time,
price,
};
}
private normalizeStop(
entryPrice: number,
rawPrice: number,
): number {
return this.side === 'long'
? Math.min(
rawPrice,
entryPrice - MIN_DISTANCE,
)
: Math.max(
rawPrice,
entryPrice + MIN_DISTANCE,
);
}
private normalizeTarget(
entryPrice: number,
rawPrice: number,
): number {
return this.side === 'long'
? Math.max(
rawPrice,
entryPrice,
)
: Math.min(
rawPrice,
entryPrice,
);
}
private clampPriceToRange(
price: number,
): number {
const range =
this.getPriceScaleRange();
if (!range) {
return price;
}
return Math.max(
range.min,
Math.min(
price,
range.max,
),
);
}
private getInitialZoneDistance(
entryPrice: number,
entryY: number,
): number | null {
const { height } =
this.container
.getBoundingClientRect();
const availableOffset =
Math.min(
entryY,
height - entryY,
) - HIT_TOLERANCE;
const offset =
Math.min(
INITIAL_ZONE_OFFSET_PX,
availableOffset,
);
if (offset <= 0) {
return null;
}
const upperPrice =
getPriceFromYCoordinate(
this.series,
entryY - offset,
);
const lowerPrice =
getPriceFromYCoordinate(
this.series,
entryY + offset,
);
if (
upperPrice === null ||
lowerPrice === null
) {
return null;
}
const minPrice =
Math.min(
upperPrice,
lowerPrice,
);
const maxPrice =
Math.max(
upperPrice,
lowerPrice,
);
const stopDistance =
this.side === 'long'
? entryPrice - minPrice
: maxPrice - entryPrice;
const targetDistance =
this.side === 'long'
? maxPrice - entryPrice
: entryPrice - minPrice;
if (
stopDistance <= 0 ||
targetDistance <= 0
) {
return null;
}
const distance =
Math.min(
stopDistance,
targetDistance /
DEFAULT_RISK_REWARD_RATIO,
);
return distance >=
MIN_DISTANCE
? distance
: null;
}
private getPriceScaleRange(): {
min: number;
max: number;
} | null {
return getPriceRangeInContainer(
this.series,
this.container,
);
}
private getPriceDelta(
fromY: number,
toY: number,
): number {
return getPriceDeltaFromCoordinates(
this.series,
fromY,
toY,
);
}
private shiftTime(
time: Time,
offsetX: number,
): Time | null {
return shiftTimeByPixels(
this.chart,
time,
offsetX,
this.series,
);
}
private getCurrentRiskRewardRatio():
number {
if (
this.entryPrice === null ||
this.stopPrice === null ||
this.targetPrice === null
) {
return DEFAULT_RISK_REWARD_RATIO;
}
const risk =
Math.abs(
this.entryPrice -
this.stopPrice,
);
if (
risk <= MIN_DISTANCE
) {
return 0;
}
return (
Math.abs(
this.targetPrice -
this.entryPrice,
) / risk
);
}
private getPriceAtTime(
time: Time | null,
): number | null {
if (
typeof time !== 'number'
) {
return null;
}
const data =
this.series.data() ?? [];
let lastPrice:
number | null = null;
for (const item of data) {
if (
typeof item.time !==
'number'
) {
continue;
}
if (item.time > time) {
break;
}
if (
'close' in item &&
typeof item.close ===
'number'
) {
lastPrice = item.close;
continue;
}
if (
'value' in item &&
typeof item.value ===
'number'
) {
lastPrice = item.value;
}
}
return lastPrice;
}
protected getDrawingHandles():
readonly DrawingHandle<SliderHandleId>[] {
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'target',
x: geometry.startX,
y: geometry.targetY,
},
{
id: 'stop',
x: geometry.startX,
y: geometry.stopY,
},
{
id: 'end',
x: geometry.endX,
y: geometry.entryY,
},
{
id: 'entry',
x: geometry.startX,
y: geometry.entryY,
shape: 'circle',
},
];
}
protected getGeometry():
SliderGeometry | null {
if (
this.startTime === null ||
this.endTime === null
) {
return null;
}
if (
this.entryPrice === null ||
this.stopPrice === null ||
this.targetPrice === null
) {
return null;
}
const startX =
getXCoordinateFromTime(
this.chart,
this.startTime,
this.series,
);
const endX =
getXCoordinateFromTime(
this.chart,
this.endTime,
this.series,
);
const entryY =
getYCoordinateFromPrice(
this.series,
this.entryPrice,
);
const stopY =
getYCoordinateFromPrice(
this.series,
this.stopPrice,
);
const targetY =
getYCoordinateFromPrice(
this.series,
this.targetPrice,
);
if (
startX === null ||
endX === null ||
entryY === null ||
stopY === null ||
targetY === null
) {
return null;
}
const start = Number(startX);
const end = Number(endX);
const entry = Number(entryY);
const stop = Number(stopY);
const target = Number(targetY);
return {
startX: start,
endX: end,
leftX: Math.min(
start,
end,
),
rightX: Math.max(
start,
end,
),
entryY: entry,
stopY: stop,
targetY: target,
entryPrice:
this.entryPrice,
stopPrice:
this.stopPrice,
targetPrice:
this.targetPrice,
profitTop: Math.min(
target,
entry,
),
profitBottom: Math.max(
target,
entry,
),
lossTop: Math.min(
stop,
entry,
),
lossBottom: Math.max(
stop,
entry,
),
};
}
private getPositionArguments():
SliderPositionArguments {
const entryPrice =
this.entryPrice ?? 0;
return {
entryPrice,
targetPrice:
this.targetPrice ??
entryPrice,
stopPrice:
this.stopPrice ??
entryPrice,
};
}
private applyPositionArguments(
values:
Partial<SliderPositionArguments>,
): void {
if (
typeof values.entryPrice ===
'number'
) {
this.entryPrice =
values.entryPrice;
}
if (
this.entryPrice === null
) {
return;
}
const entryPrice =
this.entryPrice;
if (
typeof values.targetPrice ===
'number'
) {
this.targetPrice =
this.normalizeTarget(
entryPrice,
values.targetPrice,
);
} else if (
this.targetPrice !== null
) {
this.targetPrice =
this.normalizeTarget(
entryPrice,
this.targetPrice,
);
}
if (
typeof values.stopPrice ===
'number'
) {
this.stopPrice =
this.normalizeStop(
entryPrice,
values.stopPrice,
);
} else if (
this.stopPrice !== null
) {
this.stopPrice =
this.normalizeStop(
entryPrice,
this.stopPrice,
);
}
}
private getPositionMetrics(
geometry: SliderGeometry,
selectedPrice: number | null,
): PositionMetrics {
const riskDistance =
Math.abs(
geometry.entryPrice -
geometry.stopPrice,
);
const rewardDistance =
Math.abs(
geometry.targetPrice -
geometry.entryPrice,
);
const {
accountSize,
risk,
lotSize,
leverage,
quantityPrecision,
} = this.settings;
if (
riskDistance <= MIN_DISTANCE ||
geometry.entryPrice <=
MIN_DISTANCE
) {
return {
qty: 0,
targetAmount:
accountSize,
stopAmount:
accountSize,
riskRewardRatio: 0,
openPnl:
this.getOpenPnl(
geometry.entryPrice,
selectedPrice,
),
};
}
const riskSize =
(risk / 100) *
accountSize;
const qtyRisk =
riskSize /
(
riskDistance *
POINT_VALUE
) /
lotSize;
const qtyLeverage =
(
(
accountSize *
leverage
) /
geometry.entryPrice
) *
POINT_VALUE /
lotSize;
const qty =
round(
Math.min(
qtyRisk,
qtyLeverage,
),
quantityPrecision,
);
const multiplier =
POINT_VALUE *
lotSize;
return {
qty,
targetAmount:
accountSize +
rewardDistance *
qty *
multiplier,
stopAmount:
accountSize -
riskDistance *
qty *
multiplier,
riskRewardRatio:
rewardDistance /
riskDistance,
openPnl:
this.getOpenPnl(
geometry.entryPrice,
selectedPrice,
),
};
}
private getOpenPnl(
entryPrice: number,
selectedPrice: number | null,
): number {
if (selectedPrice === null) {
return 0;
}
return this.side === 'long'
? selectedPrice -
entryPrice
: entryPrice -
selectedPrice;
}
private getTargetText(
geometry: SliderGeometry,
amount: number,
): string {
const diff =
Math.abs(
geometry.targetPrice -
geometry.entryPrice,
);
const percent =
geometry.entryPrice !== 0
? (
diff /
Math.abs(
geometry.entryPrice,
)
) *
100
: 0;
const formattedDiff =
formatPrice(diff) ?? '0';
const formattedAmount =
formatPrice(
amount,
2,
) ?? '0';
return (
`${t('Target')}: ` +
`${formattedDiff} ` +
`(${formatPercent(percent)}), ` +
`${t('Amount')}: ` +
formattedAmount
);
}
private getCenterText(
metrics: PositionMetrics,
): string {
const formattedQty =
formatPrice(
metrics.qty,
this.settings
.quantityPrecision,
) ?? '0';
const formattedRatio =
formatPrice(
metrics.riskRewardRatio,
getPercentPrecision(),
) ?? '0';
return [
`${t('Open P&L')}: ` +
`${formatSignedNumber(
metrics.openPnl,
)}, ` +
`${t('Qty')}: ` +
formattedQty,
`${t('Risk/Reward Ratio')}: ` +
formattedRatio,
].join('\n');
}
private getStopText(
geometry: SliderGeometry,
amount: number,
): string {
const diff =
Math.abs(
geometry.stopPrice -
geometry.entryPrice,
);
const percent =
geometry.entryPrice !== 0
? (
diff /
Math.abs(
geometry.entryPrice,
)
) *
100
: 0;
const formattedDiff =
formatPrice(diff) ?? '0';
const formattedAmount =
formatPrice(
amount,
2,
) ?? '0';
return (
`${t('Stop')}: ` +
`${formattedDiff} ` +
`(${formatPercent(percent)}), ` +
`${t('Amount')}: ` +
formattedAmount
);
}
private getHandleTarget(
point: Point,
): DragTarget {
const geometry =
this.getGeometry();
if (!geometry) {
return null;
}
if (
Math.abs(
point.x -
geometry.startX,
) <= HIT_TOLERANCE
) {
const entryDistance =
Math.abs(
point.y -
geometry.entryY,
);
if (entryDistance < 1) {
return 'entry';
}
const pointerSide =
Math.sign(
point.y -
geometry.entryY,
);
const targetSide =
Math.sign(
geometry.targetY -
geometry.entryY,
) ||
-Math.sign(
geometry.stopY -
geometry.entryY,
);
const stopSide =
Math.sign(
geometry.stopY -
geometry.entryY,
) ||
-Math.sign(
geometry.targetY -
geometry.entryY,
);
if (
pointerSide === targetSide &&
Math.abs(
point.y -
geometry.targetY,
) <= HIT_TOLERANCE
) {
return 'target';
}
if (
pointerSide === stopSide &&
Math.abs(
point.y -
geometry.stopY,
) <= HIT_TOLERANCE
) {
return 'stop';
}
if (
entryDistance <=
HIT_TOLERANCE
) {
return 'entry';
}
}
const handle =
this.getDrawingHandleAtPoint(
point,
);
if (handle) {
return handle.id;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(
geometry.targetY,
geometry.stopY,
),
bottom: Math.max(
geometry.targetY,
geometry.stopY,
),
};
return isPointInBounds(
point,
bounds,
)
? 'body'
: null;
}
private containsPoint(
point: Point,
): boolean {
const geometry =
this.getGeometry();
if (!geometry) {
return false;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(
geometry.targetY,
geometry.stopY,
),
bottom: Math.max(
geometry.targetY,
geometry.stopY,
),
};
return isPointInBounds(
point,
bounds,
HIT_TOLERANCE,
);
}
}
import {
CanvasRenderingTarget2D,
} from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
} from 'lightweight-charts';
import {
drawRoundedRect,
} from '@core/Drawings/utils';
import type {
SliderPositionStyle,
} from './settings';
import type {
SliderPosition,
} from './sliderPosition';
const UI = {
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
};
export class SliderPaneRenderer
implements IPrimitivePaneRenderer
{
private readonly slider:
SliderPosition;
constructor(
slider: SliderPosition,
) {
this.slider = slider;
}
public draw(
target:
CanvasRenderingTarget2D,
): void {
const data =
this.slider.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(
({
context,
horizontalPixelRatio,
verticalPixelRatio,
bitmapSize,
}) => {
const left =
data.leftX *
horizontalPixelRatio;
const right =
data.rightX *
horizontalPixelRatio;
const entryY =
data.entryY *
verticalPixelRatio;
const stopY =
data.stopY *
verticalPixelRatio;
const targetY =
data.targetY *
verticalPixelRatio;
const profitTop =
data.profitTop *
verticalPixelRatio;
const profitBottom =
data.profitBottom *
verticalPixelRatio;
const lossTop =
data.lossTop *
verticalPixelRatio;
const lossBottom =
data.lossBottom *
verticalPixelRatio;
const centerX =
(left + right) / 2;
const labelOffsetPx =
UI.labelOffset *
verticalPixelRatio;
const targetBoxHeight =
getTextBoxHeight(
data.targetText,
data,
UI.sideBoxHeight,
verticalPixelRatio,
);
const centerBoxHeight =
getTextBoxHeight(
data.centerText,
data,
UI.mainBoxHeight,
verticalPixelRatio,
);
const stopBoxHeight =
getTextBoxHeight(
data.stopText,
data,
UI.sideBoxHeight,
verticalPixelRatio,
);
const targetLabelCenterY =
getOuterLabelCenterY(
targetY,
data.targetLabelDirection,
targetBoxHeight,
labelOffsetPx,
);
const stopLabelCenterY =
getOuterLabelCenterY(
stopY,
data.stopLabelDirection,
stopBoxHeight,
labelOffsetPx,
);
const centerLabelCenterY =
getCenterLabelCenterY(
entryY,
targetY,
stopY,
centerBoxHeight,
labelOffsetPx,
bitmapSize.height,
);
context.save();
context.fillStyle =
data.positiveFillColor;
context.fillRect(
left,
profitTop,
right - left,
profitBottom -
profitTop,
);
context.fillStyle =
data.negativeFillColor;
context.fillRect(
left,
lossTop,
right - left,
lossBottom -
lossTop,
);
context.lineWidth =
data.lineWidth *
Math.max(
horizontalPixelRatio,
verticalPixelRatio,
);
context.strokeStyle =
data.lineColor;
drawHorizontalLine(
context,
left,
right,
targetY,
);
drawHorizontalLine(
context,
left,
right,
entryY,
);
drawHorizontalLine(
context,
left,
right,
stopY,
);
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
centerLabelCenterY,
data.centerText,
data.centerBoxColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.mainBoxHeight,
);
drawTextBox(
context,
centerX,
stopLabelCenterY,
data.stopText,
data.negativeFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
}
context.restore();
},
);
}
}
function getOuterLabelCenterY(
coordinate: number,
direction: 'up' | 'down',
boxHeight: number,
labelOffset: number,
): number {
const offset =
labelOffset +
boxHeight / 2;
return direction === 'up'
? coordinate - offset
: coordinate + offset;
}
function getCenterLabelCenterY(
entryY: number,
targetY: number,
stopY: number,
boxHeight: number,
labelOffset: number,
viewportHeight: number,
): number {
const targetSpace =
Math.abs(
targetY - entryY,
);
const stopSpace =
Math.abs(
stopY - entryY,
);
let direction: -1 | 1;
if (
targetSpace < stopSpace
) {
direction =
stopY < entryY
? -1
: 1;
} else {
direction =
targetY < entryY
? -1
: 1;
}
return clampTextBoxCenter(
entryY +
direction *
(
labelOffset +
boxHeight / 2
),
boxHeight,
viewportHeight,
);
}
function clampTextBoxCenter(
coordinate: number,
boxHeight: number,
viewportHeight: number,
): number {
if (
viewportHeight <= boxHeight
) {
return viewportHeight / 2;
}
const halfHeight =
boxHeight / 2;
return Math.max(
halfHeight,
Math.min(
coordinate,
viewportHeight -
halfHeight,
),
);
}
function drawHorizontalLine(
context:
CanvasRenderingContext2D,
left: number,
right: number,
y: number,
): void {
context.beginPath();
context.moveTo(
left,
y,
);
context.lineTo(
right,
y,
);
context.stroke();
}
function drawTextBox(
context:
CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle:
SliderPositionStyle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fixedHeight: number,
): void {
context.save();
const lines =
text.split('\n');
const fontSize =
textStyle.fontSize *
verticalPixelRatio;
const lineHeight =
Math.round(
textStyle.fontSize *
UI.lineHeightMultiplier,
) *
verticalPixelRatio;
const paddingX =
UI.padding *
horizontalPixelRatio;
const boxHeight =
getTextBoxHeight(
text,
textStyle,
fixedHeight,
verticalPixelRatio,
);
const radius =
UI.boxRadius *
Math.max(
horizontalPixelRatio,
verticalPixelRatio,
);
context.font =
getTextFont(fontSize);
context.textAlign =
'center';
context.textBaseline =
'middle';
let maxTextWidth = 0;
for (const line of lines) {
maxTextWidth =
Math.max(
maxTextWidth,
context
.measureText(line)
.width,
);
}
const width =
maxTextWidth +
paddingX * 2;
const x =
centerX -
width / 2;
const y =
centerY -
boxHeight / 2;
context.fillStyle =
fillColor;
context.beginPath();
drawRoundedRect(
context,
x,
y,
width,
boxHeight,
radius,
);
context.fill();
context.fillStyle =
textStyle.textColor;
if (lines.length === 1) {
context.fillText(
lines[0],
centerX,
centerY,
);
context.restore();
return;
}
const textBlockHeight =
lines.length *
lineHeight;
const firstLineCenterY =
centerY -
textBlockHeight / 2 +
lineHeight / 2;
lines.forEach(
(
line,
index,
) => {
context.fillText(
line,
centerX,
firstLineCenterY +
index *
lineHeight,
);
},
);
context.restore();
}
function getTextBoxHeight(
text: string,
textStyle:
SliderPositionStyle,
fixedHeight: number,
verticalPixelRatio: number,
): number {
const linesCount =
text
.split('\n')
.length;
const lineHeight =
Math.round(
textStyle.fontSize *
UI.lineHeightMultiplier,
) *
verticalPixelRatio;
const paddingY =
UI.padding *
verticalPixelRatio;
const minBoxHeight =
fixedHeight *
verticalPixelRatio;
return Math.max(
minBoxHeight,
linesCount *
lineHeight +
paddingY * 2,
);
}
function getTextFont(
fontSize: number,
): string {
return `${fontSize}px Inter, sans-serif`;
}
import {
SliderPaneRenderer,
} from '@core/Drawings/sliderPosition/paneRenderer';
import {
SliderPaneView,
} from '@core/Drawings/sliderPosition/paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
} from '@core/Drawings/sliderPosition/settings';
import {
SliderPosition,
type SliderRenderData,
} from '@core/Drawings/sliderPosition/sliderPosition';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createPointerEvent,
} from '../drawingTestUtils';
import type {
DrawingHandle,
} from '@core/Drawings/handles';
import type {
AxisLabel,
AxisSegment,
} from '@core/Drawings/types';
import type {
Coordinate,
MouseEventParams,
PrimitiveHoveredItem,
Time,
} from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartCrosshairLine:
'#999999',
sliderPositiveFill:
'#00aa0044',
sliderNegativeFill:
'#aa000044',
chartPriceLineText:
'#ffffff',
chartCandleUp:
'#00aa00',
chartCandleDown:
'#aa0000',
axisMarkerAreaFill:
'#2962ff44',
axisMarkerLabelFill:
'#2962ff',
axisMarkerLabelDefaultFill:
'#777777',
axisMarkerLabelPositiveFill:
'#00aa00',
axisMarkerLabelNegativeFill:
'#aa0000',
},
}),
}));
jest.mock(
'@src/translations',
() => ({
t: (
value: string,
) => value,
}),
);
jest.mock(
'@src/types/defaults',
() => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}),
);
jest.mock('@src/utils', () => ({
formatPrice: (
value: number,
precision = 4,
) => {
const factor =
10 ** precision;
return String(
Math.round(
value * factor,
) / factor,
);
},
formatPercent: (
value: number,
) => {
const factor = 100;
return (
`${
Math.round(
value * factor,
) / factor
}%`
);
},
formatSignedNumber: (
value: number,
) => String(value),
getPercentPrecision:
() => 2,
}));
jest.mock(
'@src/utils/formatter',
() => ({
formatDate: (
value: number,
) => `date:${value}`,
}),
);
class TestSliderPosition
extends SliderPosition
{
public handles():
readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments():
AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments():
AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(
kind: string,
): AxisLabel | null {
return this.getTimeAxisLabel(
kind,
);
}
public priceLabel(
kind: string,
): AxisLabel | null {
return this.getPriceAxisLabel(
kind,
);
}
public hovered(
x: number,
y: number,
): PrimitiveHoveredItem | null {
return this.getHoveredItem(
x,
y,
);
}
public pointerDown(
event: PointerEvent,
): void {
this.handlePointerDown(
event,
);
}
public pointerMove(
event: PointerEvent,
): void {
this.handlePointerMove(
event,
);
}
public pointerUp(
event: PointerEvent,
): void {
this.handlePointerUp(
event,
);
}
}
function createSlider(
side:
'long' | 'short' =
'long',
selected = false,
) {
const seriesData = [
{
time: 10 as Time,
close: 100,
},
{
time: 100 as Time,
close: 110,
},
];
const env =
createDrawingTestEnvironment({
selected,
seriesData,
});
const removeSelf =
jest.fn();
const openSettings =
jest.fn();
const slider =
new TestSliderPosition({
...env,
side,
removeSelf,
openSettings,
});
return {
...env,
slider,
removeSelf,
openSettings,
};
}
function setReady(
slider: TestSliderPosition,
): void {
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
amount: 1000,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
}
function createInitialEvent(
x: number,
y: number,
time: Time,
): MouseEventParams<Time> {
return {
point: {
x,
y,
},
time,
} as unknown as
MouseEventParams<Time>;
}
describe(
'SliderPosition',
() => {
it(
'считает Risk/Reward Ratio',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider
.getState()
.riskRewardRatio,
).toBe(2);
},
);
it(
'при риске 25% и RR 1 считает 1000 -> 1250/750',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 10 (10%), Amount: 1250',
centerText:
'Open P&L: 10, Qty: 25\n' +
'Risk/Reward Ratio: 1',
stopText:
'Stop: 10 (10%), Amount: 750',
});
},
);
it(
'ограничивает Qty через leverage',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 1,
quantityPrecision: 3,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 10 (10%), Amount: 1100',
centerText:
'Open P&L: 10, Qty: 10\n' +
'Risk/Reward Ratio: 1',
stopText:
'Stop: 10 (10%), Amount: 900',
});
},
);
it(
'всегда трактует Risk как процент от размера счёта',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 2,
lotSize: 1,
leverage: 10,
quantityPrecision: 3,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 20 (20%), Amount: 1040',
centerText:
'Open P&L: 10, Qty: 2\n' +
'Risk/Reward Ratio: 2',
stopText:
'Stop: 10 (10%), Amount: 980',
});
},
);
it(
'учитывает Lot size',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 5,
leverage: 5,
quantityPrecision: 3,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 10 (10%), Amount: 1250',
centerText:
'Open P&L: 10, Qty: 5\n' +
'Risk/Reward Ratio: 1',
stopText:
'Stop: 10 (10%), Amount: 750',
});
},
);
it(
'QTY precision округляет Qty до расчёта Amount',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 93,
targetPrice: 114,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 10,
lotSize: 1,
leverage: 10,
quantityPrecision: 2,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 14 (14%), Amount: 1200.06',
centerText:
'Open P&L: 10, Qty: 14.29\n' +
'Risk/Reward Ratio: 2',
stopText:
'Stop: 7 (7%), Amount: 899.97',
});
},
);
it(
'изменение Target не меняет Qty',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider
.getRenderData()
?.centerText,
).toContain(
'Qty: 25',
);
slider.setState({
targetPrice: 140,
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 40 (40%), Amount: 2000',
centerText:
'Open P&L: 10, Qty: 25\n' +
'Risk/Reward Ratio: 4',
stopText:
'Stop: 10 (10%), Amount: 750',
});
},
);
it(
'изменение Stop пересчитывает Qty',
() => {
const { slider } =
createSlider();
setReady(slider);
slider.setState({
stopPrice: 95,
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 20 (20%), Amount: 2000',
centerText:
'Open P&L: 10, Qty: 50\n' +
'Risk/Reward Ratio: 4',
stopText:
'Stop: 5 (5%), Amount: 750',
});
},
);
it(
'симметрично считает short позицию',
() => {
const { slider } =
createSlider('short');
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
expect(
slider.getRenderData(),
).toMatchObject({
targetText:
'Target: 20 (20%), Amount: 1500',
centerText:
'Open P&L: -10, Qty: 25\n' +
'Risk/Reward Ratio: 2',
stopText:
'Stop: 10 (10%), Amount: 750',
targetLabelDirection:
'down',
stopLabelDirection:
'up',
});
},
);
it(
'разрешает Risk/Reward Ratio = 0',
() => {
const { slider } =
createSlider();
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
riskRewardRatio: 0,
});
expect(
slider.getState(),
).toMatchObject({
targetPrice: 100,
riskRewardRatio: 0,
});
expect(
slider
.getRenderData()
?.centerText,
).toContain(
'Risk/Reward Ratio: 0',
);
},
);
it(
'возвращает четыре handles в правильном порядке',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider.handles(),
).toEqual([
{
id: 'target',
x: 10,
y: 120,
},
{
id: 'stop',
x: 10,
y: 90,
},
{
id: 'end',
x: 100,
y: 100,
},
{
id: 'entry',
x: 10,
y: 100,
shape: 'circle',
},
]);
},
);
it(
'Entry имеет приоритет при точном совпадении с Target',
() => {
const {
slider,
container,
priceToCoordinate,
} = createSlider(
'long',
true,
);
priceToCoordinate
.mockImplementation(
(
price: number,
) => {
return (
price === 101
? 100
: price
) as Coordinate;
},
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 101,
});
slider.pointerDown(
createPointerEvent(
container,
10,
100,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
95,
),
);
expect(
slider.getState(),
).toMatchObject({
entryPrice: 95,
targetPrice: 101,
});
},
);
it(
'Entry имеет приоритет при точном совпадении со Stop',
() => {
const {
slider,
container,
priceToCoordinate,
} = createSlider(
'long',
true,
);
priceToCoordinate
.mockImplementation(
(
price: number,
) => {
return (
price === 99
? 100
: price
) as Coordinate;
},
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 99,
targetPrice: 120,
});
slider.pointerDown(
createPointerEvent(
container,
10,
100,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
110,
),
);
expect(
slider.getState(),
).toMatchObject({
entryPrice: 110,
stopPrice: 99,
});
},
);
it(
'позволяет вытянуть Target из overlap',
() => {
const {
slider,
container,
priceToCoordinate,
} = createSlider(
'long',
true,
);
priceToCoordinate
.mockImplementation(
(
price: number,
) => {
return (
price === 101
? 100
: price
) as Coordinate;
},
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 101,
});
slider.pointerDown(
createPointerEvent(
container,
10,
101,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
110,
),
);
expect(
slider
.getState()
.targetPrice,
).toBe(110);
},
);
it(
'позволяет вытянуть Stop из overlap',
() => {
const {
slider,
container,
priceToCoordinate,
} = createSlider(
'long',
true,
);
priceToCoordinate
.mockImplementation(
(
price: number,
) => {
return (
price === 99
? 100
: price
) as Coordinate;
},
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 99,
targetPrice: 120,
});
slider.pointerDown(
createPointerEvent(
container,
10,
99,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
90,
),
);
expect(
slider
.getState()
.stopPrice,
).toBe(90);
},
);
it(
'выбирает Target на его стороне overlap-зоны',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 101,
});
slider.pointerDown(
createPointerEvent(
container,
10,
101,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
110,
),
);
expect(
slider
.getState()
.targetPrice,
).toBe(110);
},
);
it(
'выбирает Stop на его стороне overlap-зоны',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 99,
targetPrice: 120,
});
slider.pointerDown(
createPointerEvent(
container,
10,
99,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
90,
),
);
expect(
slider
.getState()
.stopPrice,
).toBe(90);
},
);
it(
'Entry не пересекает Stop и Target',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
setReady(slider);
slider.pointerDown(
createPointerEvent(
container,
10,
100,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
200,
),
);
expect(
slider
.getState()
.entryPrice,
).toBe(120);
},
);
it(
'Stop не пересекает Entry',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
setReady(slider);
slider.pointerDown(
createPointerEvent(
container,
10,
90,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
100,
),
);
expect(
slider
.getState()
.stopPrice,
).toBeCloseTo(100);
},
);
it(
'Target может совпасть с Entry',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
setReady(slider);
slider.pointerDown(
createPointerEvent(
container,
10,
120,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
100,
),
);
expect(
slider
.getState()
.targetPrice,
).toBe(100);
},
);
it(
'работает для short позиции',
() => {
const { slider } =
createSlider(
'short',
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
});
expect(
slider
.getState()
.riskRewardRatio,
).toBe(2);
expect(
slider.getRenderData(),
).toMatchObject({
targetLabelDirection:
'down',
stopLabelDirection:
'up',
});
},
);
it(
'для short не даёт Stop пересечь Entry и разрешает RR = 0',
() => {
const {
slider,
container,
} = createSlider(
'short',
true,
);
slider.setState({
mode: 'ready',
startTime:
10 as Time,
endTime:
100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
});
slider.pointerDown(
createPointerEvent(
container,
10,
80,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
100,
),
);
expect(
slider
.getState()
.targetPrice,
).toBe(100);
slider.pointerUp(
createPointerEvent(
container,
10,
100,
),
);
slider.pointerDown(
createPointerEvent(
container,
10,
110,
),
);
slider.pointerMove(
createPointerEvent(
container,
10,
100,
),
);
expect(
slider
.getState()
.stopPrice,
).toBeCloseTo(100);
},
);
it.each(
[
'long',
'short',
] as const,
)(
'создаёт %s позицию с ограниченной начальной высотой',
(side) => {
const seriesData = [
{
time:
150 as Time,
close: 150,
},
];
const env =
createDrawingTestEnvironment({
seriesData,
height: 300,
});
const slider =
new TestSliderPosition({
...env,
side,
initialEvent:
createInitialEvent(
100,
150,
150 as Time,
),
});
const priceHandles =
slider
.handles()
.filter(
({ id }) =>
id ===
'target' ||
id === 'stop',
);
expect(
slider
.getState()
.mode,
).toBe('ready');
expect(
priceHandles,
).toHaveLength(2);
expect(
priceHandles.every(
({ y }) =>
Math.abs(
y - 150,
) <= 60,
),
).toBe(true);
},
);
it.each(
[
['long', 20],
['long', 280],
['short', 20],
['short', 280],
] as const,
)(
'не выводит handles %s позиции за pane при y=%s',
(
side,
entryY,
) => {
const seriesData = [
{
time:
entryY as Time,
close: entryY,
},
];
const env =
createDrawingTestEnvironment({
seriesData,
height: 300,
});
const slider =
new TestSliderPosition({
...env,
side,
initialEvent:
createInitialEvent(
100,
entryY,
entryY as Time,
),
});
const priceHandles =
slider
.handles()
.filter(
({ id }) =>
id ===
'target' ||
id === 'stop',
);
expect(
slider
.getState()
.mode,
).toBe('ready');
expect(
priceHandles.every(
({ y }) =>
y >= 8 &&
y <= 292,
),
).toBe(true);
},
);
it(
'удаляет creation, если места недостаточно',
() => {
const seriesData = [
{
time: 4 as Time,
close: 4,
},
];
const env =
createDrawingTestEnvironment({
seriesData,
height: 300,
});
const removeSelf =
jest.fn();
const slider =
new TestSliderPosition({
...env,
side: 'long',
removeSelf,
initialEvent:
createInitialEvent(
100,
4,
4 as Time,
),
});
expect(
slider
.getState()
.mode,
).toBe('ready');
expect(
removeSelf,
).toHaveBeenCalledTimes(1);
},
);
it(
'поддерживает старое amount как alias для accountSize',
() => {
const { slider } =
createSlider();
slider.setState({
amount: 2000,
});
expect(
slider.getState(),
).toMatchObject({
amount: 2000,
});
expect(
slider.getSettings(),
).toMatchObject({
accountSize: 2000,
});
},
);
it(
'возвращает selected axis areas',
() => {
const { slider } =
createSlider(
'long',
true,
);
setReady(slider);
expect(
slider.timeSegments(),
).toEqual([
{
from: 10,
to: 100,
color:
'#2962ff44',
},
]);
expect(
slider.priceSegments(),
).toEqual([
{
from: 100,
to: 120,
color:
'#2962ff44',
},
{
from: 90,
to: 100,
color:
'#2962ff44',
},
]);
},
);
it(
'использует normal zOrder для axis pane areas',
() => {
const { slider } =
createSlider();
expect(
slider
.timeAxisPaneViews()[0]
.zOrder?.(),
).toBe('normal');
expect(
slider
.priceAxisPaneViews()[0]
.zOrder?.(),
).toBe('normal');
},
);
it(
'time labels показываются только selected drawing',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider.timeLabel(
'start',
),
).toBeNull();
},
);
it(
'price labels доступны независимо от selection',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider.priceLabel(
'target',
),
).toMatchObject({
coordinate: 120,
text: '120',
backgroundColor:
'#00aa00',
});
expect(
slider.priceLabel(
'entry',
),
).toMatchObject({
coordinate: 100,
backgroundColor:
'#777777',
});
expect(
slider.priceLabel(
'stop',
),
).toMatchObject({
coordinate: 90,
backgroundColor:
'#aa0000',
});
},
);
it(
'возвращает правильные cursors',
() => {
const {
slider,
} = createSlider(
'long',
true,
);
setReady(slider);
expect(
slider.hovered(
10,
120,
)?.cursorStyle,
).toBe('ns-resize');
expect(
slider.hovered(
10,
90,
)?.cursorStyle,
).toBe('ns-resize');
expect(
slider.hovered(
100,
100,
)?.cursorStyle,
).toBe('ew-resize');
expect(
slider.hovered(
10,
100,
)?.cursorStyle,
).toBe('grab');
},
);
it(
'выбирает unselected slider по body',
() => {
const {
slider,
interaction,
container,
} = createSlider();
setReady(slider);
slider.pointerDown(
createPointerEvent(
container,
50,
100,
),
);
expect(
interaction.select,
).toHaveBeenCalledTimes(1);
},
);
it(
'изменяет end time',
() => {
const {
slider,
container,
} = createSlider(
'long',
true,
);
setReady(slider);
slider.pointerDown(
createPointerEvent(
container,
100,
100,
),
);
slider.pointerMove(
createPointerEvent(
container,
150,
100,
),
);
expect(
slider
.getState()
.endTime,
).toBe(150);
},
);
},
);
describe(
'SliderPaneRenderer',
() => {
it(
'не рисует без data',
() => {
const {
target,
useBitmapCoordinateSpace,
} = createCanvasTarget();
new SliderPaneRenderer({
getRenderData:
() => null,
} as unknown as SliderPosition)
.draw(target);
expect(
useBitmapCoordinateSpace,
).not
.toHaveBeenCalled();
},
);
it(
'рисует profit/loss и labels',
() => {
const data:
SliderRenderData = {
startX: 10,
endX: 100,
leftX: 10,
rightX: 100,
entryY: 100,
stopY: 120,
targetY: 80,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
profitTop: 80,
profitBottom: 100,
lossTop: 100,
lossBottom: 120,
targetText:
'target',
centerText:
'center',
stopText:
'stop',
centerBoxColor:
'#0a0',
targetLabelDirection:
'up',
stopLabelDirection:
'down',
showLabels: true,
lineColor:
'#fff',
lineWidth: 1,
positiveFillColor:
'#0f03',
negativeFillColor:
'#f003',
textColor:
'#fff',
fontSize: 10,
showPriceLabels:
true,
alwaysShowStats:
false,
};
const {
target,
context,
} = createCanvasTarget();
new SliderPaneRenderer({
getRenderData:
() => data,
} as unknown as SliderPosition)
.draw(target);
expect(
context.fillRect,
).toHaveBeenCalledTimes(2);
expect(
context.fillText,
).toHaveBeenCalled();
expect(
context.stroke,
).toHaveBeenCalled();
},
);
},
);
describe(
'SliderPaneView',
() => {
it(
'возвращает renderer и top',
() => {
const view =
new SliderPaneView(
{} as SliderPosition,
);
expect(
view.renderer(),
).toBeInstanceOf(
SliderPaneRenderer,
);
expect(
view.zOrder(),
).toBe('top');
},
);
},
);
describe(
'SliderPosition settings',
() => {
it(
'создаёт defaults',
() => {
expect(
createDefaultSettings(),
).toEqual({
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 10000,
quantityPrecision: 3,
lineColor:
'#999999',
lineWidth: 1,
positiveFillColor:
'#00aa0044',
negativeFillColor:
'#aa000044',
textColor:
'#ffffff',
fontSize: 10,
showPriceLabels:
true,
alwaysShowStats:
false,
});
},
);
it(
'создаёт только Arguments и Style tabs',
() => {
const tabs =
getSliderPositionSettingsTabs(
createDefaultSettings(),
{
entryPrice: 100,
targetPrice: 120,
stopPrice: 90,
},
);
expect(
tabs.map(
({ key }) => key,
),
).toEqual([
'inputs',
'style',
]);
},
);
it(
'обновляет Entry, Target и Stop через настройки',
() => {
const { slider } =
createSlider();
setReady(slider);
slider.updateSettings({
entryPrice: 105,
targetPrice: 110,
stopPrice: 102,
});
expect(
slider.getState(),
).toMatchObject({
entryPrice: 105,
targetPrice: 110,
stopPrice: 102,
});
},
);
it(
'скрывает price labels',
() => {
const { slider } =
createSlider(
'long',
true,
);
setReady(slider);
slider.updateSettings({
showPriceLabels:
false,
});
expect(
slider.priceLabel(
'target',
),
).toBeNull();
expect(
slider.priceLabel(
'entry',
),
).toBeNull();
expect(
slider.priceLabel(
'stop',
),
).toBeNull();
},
);
it(
'показывает stats без selection',
() => {
const { slider } =
createSlider();
setReady(slider);
expect(
slider
.getRenderData()
?.showLabels,
).toBe(false);
slider.updateSettings({
alwaysShowStats:
true,
});
expect(
slider
.getRenderData()
?.showLabels,
).toBe(true);
},
);
},
);