Загрузка данных
import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { AxisLinePaneView } from './paneView';
import {
AxisLineSettings,
AxisLineStyle,
AxisLineTextStyle,
createDefaultSettings,
getAxisLineSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
type AxisLineHandleKey = 'main';
interface AxisLineParams extends BaseDrawingParams {
direction: AxisLineDirection;
}
interface AxisLineState {
hidden: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
settings: AxisLineSettings;
}
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
}
const LINE_HIT_TOLERANCE = 6;
export class AxisLine
extends DrawingBase<AxisLineSettings, AxisLineHandleKey>
implements ISeriesDrawing
{
private readonly openSettings?: () => void;
private readonly direction: AxisLineDirection;
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: AxisLinePaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
direction,
container,
interaction,
formatObservable,
openSettings,
initialEvent,
}: AxisLineParams) {
super({ chart, series, container, interaction });
this.direction = direction;
this.openSettings = openSettings;
this.paneView = new AxisLinePaneView(this);
this.timeAxisView = this.createTimeAxisView('main');
this.priceAxisView = this.createPriceAxisView('main');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
mode: this.mode,
time: this.time,
price: this.price,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<AxisLineState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode;
}
if ('time' in nextState) {
this.time = nextState.time ?? null;
}
if ('price' in nextState) {
this.price = nextState.price ?? null;
}
if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getAxisLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisView,
this.priceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews() {
return this.direction === 'vertical'
? [this.timeAxisView]
: [];
}
public priceAxisViews() {
return this.direction === 'horizontal'
? [this.priceAxisView]
: [];
}
public getRenderData(): AxisLineRenderData | null {
if (this.hidden) {
return null;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
return {
direction: this.direction,
coordinate,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
const coordinate = this.getCoordinate();
if (coordinate === null) {
return [];
}
const { width, height } = this.container.getBoundingClientRect();
return [
{
id: 'main',
x: this.direction === 'vertical' ? coordinate : width / 2,
y: this.direction === 'vertical' ? height / 2 : coordinate,
shape: 'rounded',
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
const point = { x, y };
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point, coordinate)) {
return null;
}
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
kind !== 'main' ||
this.direction !== 'vertical' ||
typeof this.time !== 'number'
) {
return null;
}
const coordinate = getXCoordinateFromTime(
this.chart,
this.time,
this.series,
);
return this.createAxisLabel(
coordinate === null ? null : Number(coordinate),
formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
kind !== 'main' ||
this.direction !== 'horizontal' ||
this.price === null
) {
return null;
}
const coordinate = getYCoordinateFromPrice(
this.series,
this.price,
);
return this.createAxisLabel(
coordinate === null ? null : Number(coordinate),
formatPrice(this.price) ?? '',
);
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getGeometry(): null {
return null;
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
const isNearHandle =
this.isSelected() &&
Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(
point,
coordinate,
);
if (!isNearHandle && !isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode !== 'ready') {
return;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return;
}
const isNearHandle =
this.isSelected() &&
Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(
point,
coordinate,
);
if (!this.isSelected()) {
if (!isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!isNearHandle && !isNearLine) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.updateLine(
this.getEventPoint(event),
);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId
) {
return;
}
this.mode = 'ready';
this.dragPointerId = null;
this.resolveReady?.();
this.showCrosshair();
this.render();
};
private startDrawing(point: Point): void {
this.updateLine(point);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private updateLine(point: Point): void {
if (this.direction === 'vertical') {
this.time = getTimeFromXCoordinate(
this.chart,
point.x,
);
return;
}
this.price = getPriceFromYCoordinate(
this.series,
point.y,
);
}
private getCoordinate(): number | null {
if (this.direction === 'vertical') {
if (this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(
this.chart,
this.time,
this.series,
);
return coordinate === null
? null
: Number(coordinate);
}
if (this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(
this.series,
this.price,
);
return coordinate === null
? null
: Number(coordinate);
}
private isPointNearLine(
point: Point,
coordinate: number,
): boolean {
return this.direction === 'vertical'
? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
: Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
}
private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
return this.direction === 'vertical'
? 'ew-resize'
: 'ns-resize';
}
}
import { round } from 'lodash-es';
import { skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
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 {
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 '@core/Drawings/DrawingBase';
import type {
ChartOptionsModel,
SettingsTab,
SettingsValues,
} from '@src/types';
import type {
IPrimitivePaneView,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';
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 DrawingBase<
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 =
this.createTimeAxisPaneView('normal');
this.priceAxisPaneView =
this.createPriceAxisPaneView('normal');
this.startTimeAxisView =
this.createTimeAxisView('start');
this.endTimeAxisView =
this.createTimeAxisView('end');
this.targetPriceAxisView =
this.createPriceAxisView('target');
this.entryPriceAxisView =
this.createPriceAxisView('entry');
this.stopPriceAxisView =
this.createPriceAxisView('stop');
this.subscribeFormat(
formatObservable,
(format) => {
this.displayFormat = format;
},
);
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 [];
}
return [
this.createAxisSegment(
bounds.left,
bounds.right,
),
];
}
protected getPriceAxisSegments():
AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
return [
this.createAxisSegment(
geometry.profitTop,
geometry.profitBottom,
),
this.createAxisSegment(
geometry.lossTop,
geometry.lossBottom,
),
];
}
protected getTimeAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
!this.isSelected() ||
(
kind !== 'start' &&
kind !== 'end'
)
) {
return null;
}
const labelKind =
kind as TimeLabelKind;
return this.createAxisLabel(
this.getTimeCoordinate(
labelKind,
),
this.getTimeText(
labelKind,
),
);
}
protected getPriceAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
(
kind !== 'target' &&
kind !== 'entry' &&
kind !== 'stop'
)
) {
return null;
}
const labelKind =
kind as PriceLabelKind;
const { colors } =
getThemeStore();
let backgroundColor =
colors.axisMarkerLabelDefaultFill;
if (
labelKind === 'target'
) {
backgroundColor =
colors.axisMarkerLabelPositiveFill;
} else if (
labelKind === 'stop'
) {
backgroundColor =
colors.axisMarkerLabelNegativeFill;
}
return this.createAxisLabel(
this.getPriceCoordinate(
labelKind,
),
this.getPriceText(
labelKind,
),
{
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,
);
}
}