Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion packages/meridian-charts/src/drawings/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(' | '));
Expand Down Expand Up @@ -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)), []);
Expand Down
29 changes: 17 additions & 12 deletions packages/meridian-charts/src/drawings/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
Loading