diff --git a/src/extension/i18n/en-US.ts b/src/extension/i18n/en-US.ts index 512893052..ddceac066 100644 --- a/src/extension/i18n/en-US.ts +++ b/src/extension/i18n/en-US.ts @@ -23,6 +23,9 @@ const enUS: Locales = { volume: 'Volume: ', turnover: 'Turnover: ', change: 'Change: ', + target: 'Target: ', + loss: 'Loss: ', + riskReward: 'Risk Reward: ', second: 'S', minute: '', hour: 'H', diff --git a/src/extension/i18n/zh-CN.ts b/src/extension/i18n/zh-CN.ts index 537981187..2f25a1787 100644 --- a/src/extension/i18n/zh-CN.ts +++ b/src/extension/i18n/zh-CN.ts @@ -23,6 +23,9 @@ const zhCN: Locales = { volume: '成交量:', turnover: '成交额:', change: '涨幅:', + target: '止盈: ', + loss: '止损: ', + riskReward: '盈亏比: ', second: '秒', minute: '', hour: '小时', diff --git a/src/extension/overlay/index.ts b/src/extension/overlay/index.ts index 8f65651c1..fd4d228a2 100644 --- a/src/extension/overlay/index.ts +++ b/src/extension/overlay/index.ts @@ -33,13 +33,15 @@ import verticalStraightLine from './verticalStraightLine' import simpleAnnotation from './simpleAnnotation' import simpleTag from './simpleTag' +import { longPosition, shortPosition } from './longShortPosition' + const overlays: Record = {} const extensions = [ fibonacciLine, horizontalRayLine, horizontalSegment, horizontalStraightLine, parallelStraightLine, priceChannelLine, priceLine, rayLine, segment, straightLine, verticalRayLine, verticalSegment, verticalStraightLine, - simpleAnnotation, simpleTag + simpleAnnotation, simpleTag, longPosition, shortPosition ] extensions.forEach((template: OverlayTemplate) => { diff --git a/src/extension/overlay/longShortPosition.ts b/src/extension/overlay/longShortPosition.ts new file mode 100644 index 000000000..bbe592d17 --- /dev/null +++ b/src/extension/overlay/longShortPosition.ts @@ -0,0 +1,297 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { i18n } from '../i18n' +import { isNumber, isValid } from '../../common/utils/typeChecks' +import type DeepPartial from '../../common/DeepPartial' +import type Point from '../../common/Point' +import type Coordinate from '../../common/Coordinate' +import type { OverlayTemplate, OverlayFigure, OverlayCreateFiguresCallback, OverlayCreateFiguresCallbackParams } from '../../component/Overlay' +import type { RectAttrs } from '../figure/rect' +import type { TextStyle, LineStyle, RectStyle } from '../../common/Styles' + +export interface PositionOverlayExtend { + hovered: boolean; + selected: boolean; +} + +export interface PositionOverlayStyle { + target: RectStyle; + loss: RectStyle; + targetText: TextStyle; + lossText: TextStyle; + midLine: LineStyle; +} + +function getDefaultPositionStyle (): DeepPartial { + return { + target: { color: '#279d8233' }, + loss: { color: '#f2385a33' }, + midLine: { color: '#76808F80', size: 1 }, + targetText: { + backgroundColor: '#279d82' + }, + lossText: { + backgroundColor: '#f2385a' + } + } +} + +/** + * Constrain the point alignment based on the anchor point by constraint type. + * + * @param perform - The point to be constrained + * @param anchor - The anchor point to align with + * @param constraint - The constraint type, can be "top", "bottom", "vertical", or "horizontal" + */ +function constrainPointPosition ( + perform: Partial, + anchor: Partial, + constraint: 'top' | 'bottom' | 'vertical' | 'horizontal' +): void { + if (constraint === 'top') { + if (!isNumber(perform.value) || !isNumber(anchor.value)) return + if (perform.value < anchor.value) { + perform.value = anchor.value + } + } else if (constraint === 'bottom') { + if (!isNumber(perform.value) || !isNumber(anchor.value)) return + if (perform.value > anchor.value) { + perform.value = anchor.value + } + } else if (constraint === 'vertical') { + perform.timestamp = anchor.timestamp + perform.dataIndex = anchor.dataIndex + } else { + perform.value = anchor.value + } +} + +function createRect (start: Coordinate, end: Coordinate): RectAttrs { + return { + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + width: Math.abs(start.x - end.x), + height: Math.abs(start.y - end.y) + } +} + +function createPositionRects ( + isLong: boolean +): (params: OverlayCreateFiguresCallbackParams) => OverlayFigure[] { + return ({ coordinates, overlay, yAxis, xAxis }) => { + if (!isValid(yAxis) || !isValid(xAxis)) return [] + if (coordinates.length < 2) return [] + const figures: OverlayFigure[] = [] + figures.push({ + type: 'rect', + attrs: createRect(coordinates[0], coordinates[1]), + styles: isLong ? overlay.styles?.target : overlay.styles?.loss + }) + figures.push({ + type: 'line', + attrs: { + coordinates: [{ x: coordinates[0].x, y: coordinates[1].y }, coordinates[1]] + }, + styles: overlay.styles?.midLine + }) + + if (!isValid(coordinates[2])) return figures + figures.push({ + type: 'rect', + attrs: createRect(coordinates[1], coordinates[2]), + styles: isLong ? overlay.styles?.loss : overlay.styles?.target + }) + return figures + } +} + +function createPositionInfo ( + isLong: boolean +): (params: OverlayCreateFiguresCallbackParams) => OverlayFigure[] { + return ({ chart, coordinates, overlay, yAxis, xAxis }) => { + if (!isValid(yAxis) || !isValid(xAxis)) return [] + if (coordinates.length < 3) return [] + if (overlay.currentStep !== -1) return [] + if (!overlay.extendData.hovered && !overlay.extendData.selected) return [] + + const locale = chart.getLocale() + const points = overlay.points + if ( + !isNumber(points[0].value) || + !isNumber(points[1].value) || + !isNumber(points[2].value) || + !isNumber(points[0].timestamp) || + !isNumber(points[1].timestamp) || + !isNumber(points[2].timestamp) + ) { + return [] + } + + let precision = 0 + if (yAxis.isInCandle()) { + precision = chart.getPrecision().price + } else { + const indicators = chart.getIndicators({ paneId: overlay.paneId }) + indicators.forEach((indicator) => { + precision = Math.max(precision, indicator.precision) + }) + } + const figures: OverlayFigure[] = [] + const xText = + (xAxis.convertTimestampToPixel(points[0].timestamp) + + xAxis.convertTimestampToPixel(points[1].timestamp)) / + 2 + + const upValue = chart + .getDecimalFold() + .format( + chart.getThousandsSeparator().format((points[0].value - points[1].value).toFixed(precision)) + ) + const upPercent = 100 * (points[0].value / points[1].value - 1) + const upLabel = isLong ? i18n('target', locale) : i18n('loss', locale) + figures.push({ + type: 'text', + attrs: { + x: xText, + y: coordinates[0].y, + text: `${upLabel}${upValue} (${upPercent.toFixed(2)}%)`, + baseline: 'bottom', + align: 'center' + }, + styles: isLong ? overlay.styles?.targetText : overlay.styles?.lossText + }) + + const downValue = chart + .getDecimalFold() + .format( + chart.getThousandsSeparator().format((points[1].value - points[2].value).toFixed(precision)) + ) + const downPercent = -100 * (points[2].value / points[1].value - 1) + const downLabel = isLong ? i18n('loss', locale) : i18n('target', locale) + figures.push({ + type: 'text', + attrs: { + x: xText, + y: coordinates[2].y, + text: `${downLabel}${downValue} (${downPercent.toFixed(2)}%)`, + baseline: 'top', + align: 'center' + }, + styles: isLong ? overlay.styles?.lossText : overlay.styles?.targetText + }) + + const riskReward = isLong ? upPercent / downPercent : downPercent / upPercent + if (!isNumber(riskReward) || riskReward <= 0) return figures + + const riskRewardLabel = isLong ? i18n('riskReward', locale) : i18n('riskReward', locale) + figures.push({ + type: 'text', + attrs: { + x: xText, + y: coordinates[1].y, + text: `${riskRewardLabel}${riskReward.toFixed(2)}`, + baseline: 'middle', + align: 'center' + }, + styles: overlay.styles?.targetText + }) + + return figures + } +} + +function createPositionCallback (isLong: boolean): OverlayCreateFiguresCallback { + return (params) => { + const rects = createPositionRects(isLong)(params) + const infos = createPositionInfo(isLong)(params) + return [...rects, ...infos] + } +} + +/** + * Position Overlay Template + * + * Three-point drawing specification: + * 1. First Point (Top): + * - Determines take-profit price + * - Maintains vertical alignment with third point + * 2. Second Point (Middle): + * - Defines entry price position + * - Controls horizontal width of the position area + * 3. Third Point (Bottom): + * - Specifies stop-loss price + * - Vertically aligned with first point + */ +const positionTemplate: Omit< + OverlayTemplate, + 'name' | 'createPointFigures' +> = { + styles: getDefaultPositionStyle(), + totalStep: 4, + needDefaultPointFigure: true, + needDefaultXAxisFigure: true, + needDefaultYAxisFigure: true, + extendData: { hovered: false, selected: false }, + onMouseEnter: ({ overlay }) => { overlay.extendData.hovered = true }, + onMouseLeave: ({ overlay }) => { overlay.extendData.hovered = false }, + onSelected: ({ overlay }) => { overlay.extendData.selected = true }, + onDeselected: ({ overlay }) => { overlay.extendData.selected = false }, + performEventPressedMove: ({ points, performPoint, performPointIndex }) => { + if (performPointIndex === 0) { + // Constrain first point above second point + constrainPointPosition(performPoint, points[1], 'top') + // Sync third point's X with first point + constrainPointPosition(points[2], performPoint, 'vertical') + } else if (performPointIndex === 1) { + // Keep second point between first and third + constrainPointPosition(performPoint, points[0], 'bottom') + constrainPointPosition(performPoint, points[2], 'top') + } else if (performPointIndex === 2) { + // Constrain third point below second + constrainPointPosition(performPoint, points[1], 'bottom') + // Sync first point's X with third + constrainPointPosition(points[0], performPoint, 'vertical') + } + }, + performEventMoveForDrawing: ({ points, performPoint, currentStep }) => { + if (currentStep === 2) { + // Constrain second point below first + constrainPointPosition(performPoint, points[0], 'bottom') + } else if (currentStep === 3) { + // Constrain third point below second + constrainPointPosition(performPoint, points[1], 'bottom') + + if (points[1].timestamp === performPoint.timestamp) { + // Handle initial drawing alignment + constrainPointPosition(performPoint, points[0], 'vertical') + } else { + // Maintain vertical alignment after placement + constrainPointPosition(points[0], performPoint, 'vertical') + } + } + } +} + +export const longPosition: OverlayTemplate = { + name: 'longPosition', + createPointFigures: createPositionCallback(true), + ...positionTemplate +} + +export const shortPosition: OverlayTemplate = { + name: 'shortPosition', + createPointFigures: createPositionCallback(false), + ...positionTemplate +} diff --git a/tests/html/overlay/4.html b/tests/html/overlay/4.html new file mode 100644 index 000000000..884ebf7af --- /dev/null +++ b/tests/html/overlay/4.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + Overlay long/short position + + + + +
+ + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/tests/index.html b/tests/index.html index adc652fb5..3a756c6bf 100644 --- a/tests/index.html +++ b/tests/index.html @@ -89,6 +89,7 @@

Test cases

{ title: 'Use built-in overlays', link: './html/overlay/1.html' }, { title: 'Use built-in overlays, specify points', link: './html/overlay/2.html' }, { title: 'Overlay override', link: './html/overlay/3.html' }, + { title: 'Overlay long/short position', link: './html/overlay/4.html' }, ] }, {