From 853dedc2756c49f49e338910d1b8da1dd26686ed Mon Sep 17 00:00:00 2001 From: Aaron Rono Date: Fri, 6 Nov 2026 07:44:57 +0300 Subject: [PATCH] fix: start position shading where price reaches the entry The Long and Short Position tools shaded the trade so far from the entry anchor's bar. As in TradingView, the trade now opens at the first bar that trades through the entry price, the stop and target are only checked from there, and an entry price never reached shades nothing and shows no P&L. --- CHANGELOG.md | 6 ++++ .../src/drawings/tools.test.ts | 19 +++++++++++- .../meridian-charts/src/drawings/tools.ts | 29 +++++++++++-------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 992a9ba..fc596c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Logging in to an account that is open on another computer asks first**, and **Move it here** has that computer log out of it, so the two sessions do not keep disconnecting each other. +### Fixed + +- The Long and Short Position tools shade the trade from the bar where price reached the entry, as + TradingView does, instead of from the tool's left edge. A position whose entry price is never + reached shows no shading and no P&L. + ## [1.0.0] - 2026-11-04 ### Added diff --git a/packages/meridian-charts/src/drawings/tools.test.ts b/packages/meridian-charts/src/drawings/tools.test.ts index c7dbc57..ce86b4e 100644 --- a/packages/meridian-charts/src/drawings/tools.test.ts +++ b/packages/meridian-charts/src/drawings/tools.test.ts @@ -215,7 +215,8 @@ test('position labels show the quantity from account size, risk and lot size', ( assert.ok(long.some((text) => text.includes('Risk/Reward Ratio: 2.00'))); assert.ok(long.some((text) => text.startsWith('Target: 110.00') && text.includes('Amount: 20'))); assert.ok(long.some((text) => text.startsWith('Stop: 95.00') && text.includes('Amount: 10'))); - assert.ok(long.some((text) => /(Open|Closed) P&L: /.test(text))); + const filled = texts(build('long-position', [at(10, 105), at(30, 115), at(30, 100)], full)); + assert.ok(filled.some((text) => /(Open|Closed) P&L: /.test(text)), 'P&L once price reaches the entry'); // 2% of 5000 = 100 over 5 points with a lot size of 4: a quantity of 5. const custom = texts(build('long-position', [at(10, 100), at(30, 110), at(30, 95)], { ...full, accountSize: 5000, riskPercent: 2, lotSize: 4 })); assert.ok(custom.some((text) => text.includes('Qty: 5')), custom.join(' | ')); @@ -260,6 +261,22 @@ test('a position stops at the bar that hits its target or stop', () => { assert.equal(progressOf(build('long-position', [at(70, 105), at(90, 200), at(90, 50)])).length, 0, 'no bars after the entry, nothing to shade'); }); +test('a position opens where price first reaches the entry, not at the entry anchor', () => { + const entry = 115; + const touch = bars.findIndex((bar, index) => index >= 10 && bar.low <= entry && bar.high >= entry); + assert.ok(touch > 10); + const shapes = build('long-position', [at(10, entry), at(80, 200), at(80, 50)]); + const [progress] = progressOf(shapes); + assert.ok(progress?.kind === 'rect'); + assert.equal(progress.x, timeToX(bars[touch].time)); + const trace = shapes.find((shape) => shape.kind === 'line' && shape.style.dash?.length); + assert.ok(trace?.kind === 'line'); + assert.deepEqual(trace.a, { x: timeToX(bars[touch].time), y: priceToY(entry) }); + const unfilled = build('long-position', [at(10, 300), at(80, 400), at(80, 50)], { showStats: true, isCompact: false }); + assert.equal(progressOf(unfilled).length, 0, 'an entry price never reached opens nothing'); + assert.ok(!texts(unfilled).some((text) => text.includes('P&L'))); +}); + test('line tools append price, bars and angle info on request', () => { const anchors = [at(5, 100), at(15, 110)]; assert.deepEqual(texts(build('trend-line', anchors)), []); diff --git a/packages/meridian-charts/src/drawings/tools.ts b/packages/meridian-charts/src/drawings/tools.ts index 96ac689..3fcc741 100644 --- a/packages/meridian-charts/src/drawings/tools.ts +++ b/packages/meridian-charts/src/drawings/tools.ts @@ -559,17 +559,21 @@ const positionFields: ToolField[] = [ checkbox('showStats', 'Always show stats'), ]; -// Follows price from the entry bar to the end of the position: the trade closes at the first bar -// that touches the stop (checked first) or the target, else it is still open at the last close. -function positionOutcome(env: ToolEnv, isLong: boolean, entry: DrawingPoint, end: number, target: number, stop: number): { exit: number; index: number; isClosed: boolean } | null { +// As in TradingView, the trade opens at the first bar from the entry anchor that trades through the +// entry price, and closes at the first bar from there that touches the stop (checked first) or the +// target, else it is still open at the last close. A price that never reaches the entry opens nothing. +function positionOutcome(env: ToolEnv, isLong: boolean, entry: DrawingPoint, end: number, target: number, stop: number): { exit: number; start: number; index: number; isClosed: boolean } | null { const range = barRange(env, entry.time, end); if (!range) return null; - for (let index = range[0]; index <= range[1]; index++) { + let start = range[0]; + while (start <= range[1] && (env.bars[start].low > entry.price || env.bars[start].high < entry.price)) start++; + if (start > range[1]) return null; + for (let index = start; index <= range[1]; index++) { const bar = env.bars[index]; - if (isLong ? bar.low <= stop : bar.high >= stop) return { exit: stop, index, isClosed: true }; - if (isLong ? bar.high >= target : bar.low <= target) return { exit: target, index, isClosed: true }; + if (isLong ? bar.low <= stop : bar.high >= stop) return { exit: stop, start, index, isClosed: true }; + if (isLong ? bar.high >= target : bar.low <= target) return { exit: target, start, index, isClosed: true }; } - return { exit: env.bars[range[1]].close, index: range[1], isClosed: false }; + return { exit: env.bars[range[1]].close, start, index: range[1], isClosed: false }; } function position(isLong: boolean): Build { @@ -586,15 +590,16 @@ function position(isLong: boolean): Build { const opacity = o.num('areaOpacity') / 100; g.rect({ x: x0, y: entry.y }, { x: x1, y: target.y }, { width: 0, fill: profitColor, fillOpacity: opacity }); g.rect({ x: x0, y: entry.y }, { x: x1, y: stop.y }, { width: 0, fill: stopColor, fillOpacity: opacity }); - // As in TradingView, the trade so far is shaded darker: from the entry to the bar where it - // stands (the latest bar while open, the bar that hit the target or stop once closed), between - // the entry and that price, with a dashed line from the entry to it. + // As in TradingView, the trade so far is shaded darker: from the bar where price reached the + // entry to the bar where it stands (the latest bar while open, the bar that hit the target or + // stop once closed), between the entry and that price, with a dashed line along it. const outcome = targetPoint.time > entryPoint.time ? positionOutcome(env, isLong, entryPoint, targetPoint.time, targetPrice, stopPrice) : null; if (outcome) { + const opened = { x: env.timeToX(env.bars[outcome.start].time), y: entry.y }; const exit = { x: env.timeToX(env.bars[outcome.index].time), y: env.priceToY(outcome.exit) }; const isProfit = (outcome.exit - entryPrice) * (isLong ? 1 : -1) >= 0; - g.rect({ x: x0, y: entry.y }, exit, { width: 0, fill: isProfit ? profitColor : stopColor, fillOpacity: Math.min(1, opacity + 0.2) }); - g.line({ x: x0, y: entry.y }, exit, { color: env.theme.muted, width: 1, dash: [4, 4] }); + g.rect(opened, exit, { width: 0, fill: isProfit ? profitColor : stopColor, fillOpacity: Math.min(1, opacity + 0.2) }); + g.line(opened, exit, { color: env.theme.muted, width: 1, dash: [4, 4] }); } g.line({ x: x0, y: entry.y }, { x: x1, y: entry.y }, { color: env.theme.muted, width: 1 }); const reward = Math.abs(targetPrice - entryPrice);