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);