fix future drawing interactions

This commit is contained in:
Chris Amow 2026-08-25 05:41:55 -05:00
parent 90fc77df92
commit 5468bbec60
10 changed files with 365 additions and 59 deletions

View file

@ -120,9 +120,9 @@ geometry change that needs explicit browser verification.
- The displayed time scale, a drawing's source-timeframe bar space, and the - The displayed time scale, a drawing's source-timeframe bar space, and the
server session calendar are different coordinate systems. Never substitute server session calendar are different coordinate systems. Never substitute
wall-clock seconds or the displayed grid for canonical source geometry. wall-clock seconds or the displayed grid for canonical source geometry.
- Daily future slots must skip non-session days. Intraday future slots cross the - Daily and intraday future slots must skip non-session time. An endpoint that
settlement break and weekend today; an endpoint that looks valid before the looks valid before a settlement/weekend break must remain resolvable when real
break can become unresolved when real history arrives. history arrives.
- Do not clamp a future click to the last real candle. Do not let one null or - Do not clamp a future click to the last real candle. Do not let one null or
unpriceable future sample disable an otherwise valid object's whole hit target. unpriceable future sample disable an otherwise valid object's whole hit target.
- DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating - DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating

View file

@ -100,7 +100,7 @@ class Runtime:
self.broadcast({"type": "bar", "bar": provisional}) self.broadcast({"type": "bar", "bar": provisional})
return return
evaluate_alerts = False alert_bar: Bar | None = None
for aggregated in self.aggregator.update(bar): for aggregated in self.aggregator.update(bar):
self.store.put(aggregated) self.store.put(aggregated)
if self.seeding: if self.seeding:
@ -116,14 +116,18 @@ class Runtime:
self.request_rebuild() self.request_rebuild()
if aggregated.tf is Timeframe.M1 and aggregated.closed: if aggregated.tf is Timeframe.M1 and aggregated.closed:
self.price = aggregated.c self.price = aggregated.c
evaluate_alerts = True alert_bar = aggregated
if evaluate_alerts: if alert_bar is not None:
values = atr(self.store.get(Timeframe.M15), 14) values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# VWAP re-prices every minute, so levels are rebuilt here too. The # VWAP re-prices every minute, so levels are rebuilt here too. The
# broadcast is a delta, which is what keeps that affordable. # broadcast is a delta, which is what keeps that affordable.
self.request_rebuild() self.request_rebuild()
self.rebuild_clusters(evaluate_alerts=True) self.rebuild_clusters(
evaluate_alerts=True,
alert_t=alert_bar.t,
alert_price=alert_bar.c,
)
def provisional_higher(self, bar: Bar) -> list[Bar]: def provisional_higher(self, bar: Bar) -> list[Bar]:
"""Higher-timeframe bars including the minute still being traded. """Higher-timeframe bars including the minute still being traded.
@ -209,7 +213,9 @@ class Runtime:
self.broadcast_level_delta() self.broadcast_level_delta()
self.rebuild_clusters() self.rebuild_clusters()
def position_manual_levels(self, levels: list[Level], bars: list[Bar]) -> None: def position_manual_levels(
self, levels: list[Level], bars: list[Bar], now: int | None = None,
) -> None:
"""Price sloped lines across bars rather than seconds. """Price sloped lines across bars rather than seconds.
The chart spaces bars evenly, so the line a person drew advances per bar. The chart spaces bars evenly, so the line a person drew advances per bar.
@ -219,7 +225,7 @@ class Runtime:
if not bars: if not bars:
return return
minute_times = [bar.t for bar in bars] minute_times = [bar.t for bar in bars]
now = minute_times[-1] now = minute_times[-1] if now is None else now
for level in levels: for level in levels:
if not level.slope: if not level.slope:
continue continue
@ -245,7 +251,12 @@ class Runtime:
if changed or removed: if changed or removed:
self.broadcast({"type": "levels", "changed": changed, "removed": removed}) self.broadcast({"type": "levels", "changed": changed, "removed": removed})
def rebuild_clusters(self, evaluate_alerts: bool = False) -> None: def rebuild_clusters(
self,
evaluate_alerts: bool = False,
alert_t: int | None = None,
alert_price: float | None = None,
) -> None:
if self.price is None or self.stream.last_bar_t is None: if self.price is None or self.stream.last_bar_t is None:
return return
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15) self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
@ -254,12 +265,26 @@ class Runtime:
# Evaluated over every level, deliberately ignoring per-connection # Evaluated over every level, deliberately ignoring per-connection
# layer preferences: those are a display choice made in one browser, # layer preferences: those are a display choice made in one browser,
# and a push notification has no business depending on them. # and a push notification has no business depending on them.
evaluation_t = self.stream.last_bar_t if alert_t is None else alert_t
evaluation_price = self.price if alert_price is None else alert_price
alert_levels = [replace(level) for level in self.levels]
self.position_manual_levels(
alert_levels,
self.store.get(Timeframe.M1),
evaluation_t,
)
alert_clusters = cluster_levels(
alert_levels,
evaluation_t,
evaluation_price,
self.atr15,
)
self.dispatch_alerts( self.dispatch_alerts(
self.alert_engine.evaluate( self.alert_engine.evaluate(
self.clusters, alert_clusters,
self.price, evaluation_price,
self.atr15, self.atr15,
self.stream.last_bar_t, evaluation_t,
self.stream.symbol, self.stream.symbol,
self.watched_ma_levels(), self.watched_ma_levels(),
) )

View file

@ -1293,3 +1293,29 @@ actual future click instead of reducing it to the nearest real candle.
Regression coverage pins daily weekends, intraday settlement/weekend rollover, Regression coverage pins daily weekends, intraday settlement/weekend rollover,
the shared horizon, display payload, daily live duration, beyond-horizon refusal, the shared horizon, display payload, daily live duration, beyond-horizon refusal,
and future cutoff timestamps. and future cutoff timestamps.
### 2026-08-25 — future interactions use owned coordinates and valid samples
The canonical calendar fixed timestamps but several interaction paths still used
the last two real candles as a pixel ruler. Sparse-gap whitespace made that ruler
wrong for future handles, hit targets, Fibonacci rails, and annotations. Exact
bars and server-owned future slots now use Lightweight Charts' own coordinates;
future index conversion advances through the actual shared logical scale.
The same audit found independent interaction bugs: hidden sloped lines could win
hit testing; Fibonacci levels selected left of their origin and exposed an inert
diagonal move target; pinned annotation nudges invented calendar timestamps;
floating/parked annotations used the outer chart instead of plot dimensions.
Hidden lines are excluded, Fibonacci selects only on its visible side and drags
from its vertical rail, annotation shifts use visible canonical slots, and all
annotation fractions/clamps are plot-relative.
Alert evaluation also mixed a settled price with the provisional next minute's
trendline timestamp. Alerts now clone and reprice manual levels at the exact
closed bar before clustering. That same fresh positioning excludes an anchor as
soon as source-ring eviction makes it unresolved, rather than allowing one stale
alert before the deferred global rebuild.
Finally, source-dependent shift operations no longer silently keep the old time
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
and nudge abort cleanly when the canonical shift cannot be represented.

View file

@ -1054,7 +1054,7 @@ createApp({
} }
function shiftTimeByBars(line, time, count = 10) { function shiftTimeByBars(line, time, count = 10) {
return chartApi?.shiftLineTime(line, time, count) ?? time; return chartApi?.shiftLineTime(line, time, count) ?? null;
} }
function duplicateName(line) { function duplicateName(line) {
@ -1070,6 +1070,8 @@ createApp({
if (line.geometry_resolved === false) return; if (line.geometry_resolved === false) return;
const anchorT = shiftTimeByBars(line, line.anchor_t); const anchorT = shiftTimeByBars(line, line.anchor_t);
const endT = shiftTimeByBars(line, line.last_t); const endT = shiftTimeByBars(line, line.last_t);
const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t);
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) return;
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', { const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -1079,7 +1081,7 @@ createApp({
end_t: endT, end_p: endP, end_t: endT, end_p: endP,
note: duplicateName(line), hidden: line.hidden, note: duplicateName(line), hidden: line.hidden,
color: line.color || '#65b7cf', line_width: line.line_width || 2, color: line.color || '#65b7cf', line_width: line.line_width || 2,
cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t), cutoff_t: cutoffT,
armed: line.armed, armed: line.armed,
}), }),
}); });
@ -1255,10 +1257,15 @@ createApp({
+ vertical * ConfluenceChart.TICK; + vertical * ConfluenceChart.TICK;
if (horizontal) { if (horizontal) {
if (line.geometry_resolved === false) continue; if (line.geometry_resolved === false) continue;
line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal); const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal); const lastT = chartApi.shiftLineTime(line, line.last_t, horizontal);
const cutoffT = line.cutoff_t == null
? null : chartApi.shiftLineTime(line, line.cutoff_t, horizontal);
if (anchorT == null || lastT == null || (line.cutoff_t != null && cutoffT == null)) continue;
line.anchor_t = anchorT;
line.last_t = lastT;
if (line.cutoff_t != null) { if (line.cutoff_t != null) {
line.cutoff_t = chartApi.shiftLineTime(line, line.cutoff_t, horizontal); line.cutoff_t = cutoffT;
} }
} }
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
@ -1274,9 +1281,7 @@ createApp({
); );
} }
if (horizontal) { if (horizontal) {
changes.anchor_t = chartApi.timeAtIndex( changes.anchor_t = chartApi.shiftDisplayedTime(comment.anchor_t, horizontal);
chartApi.indexAt(comment.anchor_t) + horizontal,
);
} }
if (Object.keys(changes).length) await updateCommentGeometry(comment, changes); if (Object.keys(changes).length) await updateCommentGeometry(comment, changes);
} else { } else {
@ -1285,9 +1290,9 @@ createApp({
const clamp = value => Math.min(Math.max(value, 0), 1); const clamp = value => Math.min(Math.max(value, 0), 1);
await updateCommentGeometry(comment, { await updateCommentGeometry(comment, {
x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0) x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0)
/ chartApi.chartEl.clientWidth), / chartApi.overlayLayer.clientWidth),
y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0) y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0)
/ chartApi.chartEl.clientHeight), / chartApi.overlayLayer.clientHeight),
}); });
} }
} }

View file

@ -1079,8 +1079,8 @@ class ConfluenceChart {
// and a floating one must ignore the time scale entirely. // and a floating one must ignore the time scale entirely.
renderComments() { renderComments() {
if (!this.commentLayer) return; if (!this.commentLayer) return;
const width = this.chartEl.clientWidth; const width = this.overlayLayer.clientWidth;
const height = this.chartEl.clientHeight; const height = this.overlayLayer.clientHeight;
const seen = new Set(); const seen = new Set();
for (const comment of this.comments || []) { for (const comment of this.comments || []) {
seen.add(comment.id); seen.add(comment.id);
@ -1115,8 +1115,10 @@ class ConfluenceChart {
if (!comment.pinned) { if (!comment.pinned) {
// A fraction of the pane, so it holds its place through any zoom. // A fraction of the pane, so it holds its place through any zoom.
node.root.style.left = `${Math.round(comment.x * width)}px`; const left = Math.min(Math.max(comment.x * width, 0), Math.max(0, width - node.root.offsetWidth));
node.root.style.top = `${Math.round(comment.y * height)}px`; const top = Math.min(Math.max(comment.y * height, 0), Math.max(0, height - node.root.offsetHeight));
node.root.style.left = `${Math.round(left)}px`;
node.root.style.top = `${Math.round(top)}px`;
node.root.classList.remove('off-left', 'off-right'); node.root.classList.remove('off-left', 'off-right');
continue; continue;
} }
@ -1242,8 +1244,8 @@ class ConfluenceChart {
} }
return; return;
} }
const x = (upEvent.clientX - bounds.left - 8) / bounds.width; const x = (upEvent.clientX - bounds.left - root.offsetWidth / 2) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height; const y = (upEvent.clientY - bounds.top - root.offsetHeight / 2) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1); const clamp = value => Math.min(Math.max(value, 0), 1);
if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) }); if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) });
}; };
@ -1660,6 +1662,11 @@ class ConfluenceChart {
const scale = this.chart.timeScale(); const scale = this.chart.timeScale();
if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t); if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t);
const last = this.bars.length - 1; const last = this.bars.length - 1;
if (index >= last) {
const lastX = scale.timeToCoordinate(this.bars[last].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
return lastLogical == null ? null : scale.logicalToCoordinate(lastLogical + index - last);
}
const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index); const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index);
const upper = lower + 1; const upper = lower + 1;
const x1 = scale.timeToCoordinate(this.bars[lower].t); const x1 = scale.timeToCoordinate(this.bars[lower].t);
@ -1670,6 +1677,9 @@ class ConfluenceChart {
coordinateAtTime(time) { coordinateAtTime(time) {
if (!this.bars.length) return null; if (!this.bars.length) return null;
const owned = this.bars.some(bar => bar.t === time)
|| this.timeScaleSlots(this.bars).some(point => point.time === time);
if (owned) return this.chart.timeScale().timeToCoordinate(time);
// Interpolate between the displayed candles' own pixel coordinates. The // Interpolate between the displayed candles' own pixel coordinates. The
// shared scale may contain foreign timestamps from other series, and its // shared scale may contain foreign timestamps from other series, and its
// logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h. // logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h.
@ -2266,7 +2276,8 @@ class ConfluenceChart {
const distance = y == null ? Infinity : Math.abs(y - param.point.y); const distance = y == null ? Infinity : Math.abs(y - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
} }
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { for (const level of this.levels.filter(value =>
value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) {
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const endIndex = Math.min(naturalEnd, visible.to); const endIndex = Math.min(naturalEnd, visible.to);
@ -2279,6 +2290,8 @@ class ConfluenceChart {
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
} }
for (const fib of this.fibs.filter(item => !item.hidden)) { for (const fib of this.fibs.filter(item => !item.hidden)) {
const originX = this.coordinateAtTime(fib.anchor_t);
if (originX == null || param.point.x < originX) continue;
for (const level of ConfluenceChart.fibPrices(fib)) { for (const level of ConfluenceChart.fibPrices(fib)) {
const y = this.candles.priceToCoordinate(level.price); const y = this.candles.priceToCoordinate(level.price);
const distance = y == null ? Infinity : Math.abs(y - param.point.y); const distance = y == null ? Infinity : Math.abs(y - param.point.y);
@ -2302,11 +2315,13 @@ class ConfluenceChart {
} }
const last = bars.length - 1; const last = bars.length - 1;
if (time >= bars[last].t) { if (time >= bars[last].t) {
// Future slots use the median interval, not the tail gap. After the CME const future = this.futureTimes(bars).map(point => point.time);
// settlement break the final gap is 65 minutes, and using it here makes a if (!future.length) return last;
// 5m future projection advance thirteen bars per visible slot. let lower = -1;
const step = ConfluenceChart.barInterval(bars); while (lower + 1 < future.length && future[lower + 1] <= time) lower += 1;
return last + (step ? (time - bars[last].t) / step : 0); if (lower < 0) return last + (time - bars[last].t) / (future[0] - bars[last].t);
if (future[lower] === time || lower === future.length - 1) return last + lower + 1;
return last + lower + 1 + (time - future[lower]) / (future[lower + 1] - future[lower]);
} }
let lo = 0; let lo = 0;
let hi = last; let hi = last;
@ -2394,9 +2409,18 @@ class ConfluenceChart {
shiftLineTime(level, time, bars) { shiftLineTime(level, time, bars) {
const source = this.sourceSeries(level); const source = this.sourceSeries(level);
if (!source) return this.timeAtIndex(this.indexAt(time) + bars); if (!source) return this.shiftDisplayedTime(time, bars);
const index = ConfluenceChart.timeframeIndexAt(source, time); const index = ConfluenceChart.timeframeIndexAt(source, time);
return index == null ? time : ConfluenceChart.timeframeTimeAt(source, index + bars) ?? time; return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars);
}
shiftDisplayedTime(time, slots) {
const scale = this.chart.timeScale();
const x = this.coordinateAtTime(time);
const logical = x == null ? null : scale.coordinateToLogical(x);
const targetX = logical == null ? null : scale.logicalToCoordinate(logical + slots);
const shifted = targetX == null ? null : scale.coordinateToTime(targetX);
return shifted == null ? null : Number(shifted);
} }
timeAtIndex(index) { timeAtIndex(index) {
@ -2405,8 +2429,15 @@ class ConfluenceChart {
if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t)); if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t));
const last = bars.length - 1; const last = bars.length - 1;
if (index >= last) { if (index >= last) {
const step = ConfluenceChart.barInterval(bars); const offset = index - last;
return Math.round(bars[last].t + (index - last) * step); if (offset === 0) return bars[last].t;
const future = this.futureTimes(bars).map(point => point.time);
const lower = Math.floor(offset);
const fraction = offset - lower;
const t1 = lower === 0 ? bars[last].t : future[lower - 1];
const t2 = future[lower];
if (t1 == null) return bars[last].t;
return t2 == null ? t1 : Math.round(t1 + fraction * (t2 - t1));
} }
const lower = Math.floor(index); const lower = Math.floor(index);
const fraction = index - lower; const fraction = index - lower;
@ -2678,6 +2709,34 @@ class ConfluenceChart {
value: level.anchor_p + level.slope * (level.last_t - level.anchor_t), value: level.anchor_p + level.slope * (level.last_t - level.anchor_t),
}, },
]; ];
if (level.kind === 'fibonacci') {
const x = this.coordinateAtTime(level.anchor_t);
const y1 = this.candles.priceToCoordinate(points[0].value);
const y2 = this.candles.priceToCoordinate(points[1].value);
if ([x, y1, y2].some(value => value == null)) {
this.lineHitTarget.setAttribute('hidden', '');
} else {
this.lineHitTarget.removeAttribute('hidden');
this.lineHitTarget.setAttribute('points', `${x},${y1} ${x},${y2}`);
this.lineHitTarget.setAttribute('x1', x);
this.lineHitTarget.setAttribute('y1', y1);
this.lineHitTarget.setAttribute('x2', x);
this.lineHitTarget.setAttribute('y2', y2);
}
points.forEach((point, index) => {
const handle = this.anchorHandles[index];
const handleX = this.coordinateAtTime(point.time);
const handleY = this.candles.priceToCoordinate(point.value);
if (handleX == null || handleY == null) handle.setAttribute('hidden', '');
else {
handle.removeAttribute('hidden');
handle.setAttribute('cx', handleX);
handle.setAttribute('cy', handleY);
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
}
});
return;
}
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd); const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd);
@ -2730,7 +2789,7 @@ class ConfluenceChart {
startLineDrag(event) { startLineDrag(event) {
if (event.button !== 0) return; if (event.button !== 0) return;
const level = this.levels.find(value => value.id === this.selectedLineId); const level = this.handleLevel(this.selectedLineId);
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!level || level.geometry_resolved === false || !point || point.t == null) return; if (!level || level.geometry_resolved === false || !point || point.t == null) return;
event.preventDefault(); event.preventDefault();
@ -2748,7 +2807,7 @@ class ConfluenceChart {
moveLine(event) { moveLine(event) {
if (!this.draggingLine) return; if (!this.draggingLine) return;
const point = this.eventPoint(event); const point = this.eventPoint(event);
const level = this.levels.find(value => value.id === this.draggingLine.id); const level = this.handleLevel(this.draggingLine.id);
if (!point || point.t == null || !this.withinPlot(point) || !level) return; if (!point || point.t == null || !this.withinPlot(point) || !level) return;
event.preventDefault(); event.preventDefault();
const { original, start } = this.draggingLine; const { original, start } = this.draggingLine;
@ -2767,6 +2826,13 @@ class ConfluenceChart {
const lastT = source const lastT = source
? this.shiftLineTime(original, original.last_t, indexShift) ? this.shiftLineTime(original, original.last_t, indexShift)
: this.timeAtIndex(this.indexAt(original.last_t) + indexShift); : this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
if (anchorT == null || lastT == null) return;
const cutoffT = original.cutoff_t == null
? null
: source
? this.shiftLineTime(original, original.cutoff_t, indexShift)
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
if (original.cutoff_t != null && cutoffT == null) return;
const endPrice = original.anchor_p const endPrice = original.anchor_p
+ original.slope * (original.last_t - original.anchor_t) + priceShift; + original.slope * (original.last_t - original.anchor_t) + priceShift;
level.anchor_t = anchorT; level.anchor_t = anchorT;
@ -2774,13 +2840,12 @@ class ConfluenceChart {
level.anchor_p = original.anchor_p + priceShift; level.anchor_p = original.anchor_p + priceShift;
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
if (original.cutoff_t != null) { if (original.cutoff_t != null) {
level.cutoff_t = source level.cutoff_t = cutoffT;
? this.shiftLineTime(original, original.cutoff_t, indexShift)
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
} }
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
if (level.kind === 'fibonacci') this.syncFibLines();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderLineBridges(); this.renderLineBridges();
} }
@ -2788,7 +2853,7 @@ class ConfluenceChart {
finishLineDrag(event) { finishLineDrag(event) {
if (!this.draggingLine) return; if (!this.draggingLine) return;
event.preventDefault(); event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingLine.id); const level = this.handleLevel(this.draggingLine.id);
const moved = this.draggingLine.moved; const moved = this.draggingLine.moved;
const original = this.draggingLine.original; const original = this.draggingLine.original;
this.draggingLine = null; this.draggingLine = null;

View file

@ -167,7 +167,7 @@ test('the status row shows the exact local time data last arrived',
return freshness?.textContent.trim() !== '—'; return freshness?.textContent.trim() !== '—';
}); });
const state = await page.evaluate(() => { const state = await page.evaluate(() => {
const freshness = document.querySelector('.data-freshness'); const freshness = document.querySelector('.data-freshness span:last-child');
return { return {
freshness: freshness.textContent.trim(), freshness: freshness.textContent.trim(),
deployMetadata: document.querySelector('.app-meta'), deployMetadata: document.querySelector('.app-meta'),
@ -198,7 +198,7 @@ test('the quote, current-price marker, and tool order retain their trading conte
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/); assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']); assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']);
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Trendline', 'Comment']); assert.deepEqual(state.tools, ['Mark', 'Price level', 'Fibonacci', 'Trendline', 'Comment']);
assert.equal(state.candleOptions.lastValueVisible, false); assert.equal(state.candleOptions.lastValueVisible, false);
assert.equal(state.candleOptions.priceLineVisible, false); assert.equal(state.candleOptions.priceLineVisible, false);
assert.equal(state.markerOptions.lineWidth, 1); assert.equal(state.markerOptions.lineWidth, 1);
@ -219,7 +219,7 @@ test('a daily crosshair shows the session date, not the previous evening',
return { return {
actual: c.chart.options().localization.timeFormatter(time), actual: c.chart.options().localization.timeFormatter(time),
expected: new Date(time * 1000).toLocaleString(undefined, { expected: new Date(time * 1000).toLocaleString(undefined, {
timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
}), }),
}; };
}); });

View file

@ -53,6 +53,9 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as
}, TEXT); }, TEXT);
assert.ok(anchor, 'the comment was not created'); assert.ok(anchor, 'the comment was not created');
await page.locator('.config-section summary').click();
await page.locator('.config-section label:has-text("Hide lower-TF drawings") input').uncheck();
await page.fill('input[aria-label="Filter drawings by text"]', TEXT); await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row .line-select').check(); await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
@ -88,11 +91,8 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
assert.deepEqual(controls, ['collapse-toggle', 'color'], assert.deepEqual(controls, ['collapse-toggle', 'color'],
'the comment color picker is not the rightmost control'); 'the comment color picker is not the rightmost control');
await row.locator('.drawing-secondary>span').click(); await row.locator('.drawing-secondary>span').click();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), false,
'clicking drawing-list content selected the comment');
await row.locator('.line-select').check();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true, assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
'selecting the comment checkbox did not point out the comment on the chart'); 'clicking drawing-list content did not point out the comment on the chart');
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false, assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection'); 'Escape did not clear comment selection');
@ -132,6 +132,31 @@ test('a floating comment holds its position while the chart scrolls', { timeout:
}); });
}); });
test('a floating comment stays inside the plot at the future-side edge',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.evaluate(() => {
const chart = window.__chart;
chart.setComments([{
id: 'floating-edge', kind: 'comment', tf: '1m', note: 'edge', hidden: false,
pinned: false, x: 1, y: 0.5, collapsed: false, color: '#c8992f', scale: 1,
}]);
});
await page.waitForFunction(() => document.querySelector('[data-drawing-id="floating-edge"]'));
const result = await page.evaluate(() => {
const chart = window.__chart;
const node = document.querySelector('[data-drawing-id="floating-edge"]');
const box = node.getBoundingClientRect();
const plot = chart.plotCanvas().getBoundingClientRect();
return { right: box.right, bottom: box.bottom, plotRight: plot.right, plotBottom: plot.bottom };
});
assert.ok(result.right <= result.plotRight + 1, 'floating comment overflowed the right price axis');
assert.ok(result.bottom <= result.plotBottom + 1, 'floating comment overflowed the plot bottom');
assertNoPageErrors(page, assert);
});
});
test('unchecking Drawings hides comments on the chart', { timeout: 180000 }, async () => { test('unchecking Drawings hides comments on the chart', { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
const box = await chartBox(page); const box = await chartBox(page);

View file

@ -302,7 +302,7 @@ test('a price level can be edited from the list and adjusted on the chart',
await row.locator('input[aria-label="blue row annotation"]').press('Tab'); await row.locator('input[aria-label="blue row annotation"]').press('Tab');
assert.equal(await page.evaluate(() => assert.equal(await page.evaluate(() =>
JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure'); JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure');
await row.locator('button[aria-label="Use blue2 (#4699FE)"]').click(); await row.locator('button[aria-label="Use blue2 (#4699FE)"]').evaluate(node => node.click());
const id = await page.evaluate(name => const id = await page.evaluate(name =>
window.__chart.levels.find(level => level.label === name)?.id || null, label); window.__chart.levels.find(level => level.label === name)?.id || null, label);
assert.ok(id, 'the price level was not created'); assert.ok(id, 'the price level was not created');
@ -326,9 +326,6 @@ test('a price level can be edited from the list and adjusted on the chart',
'an unselected price level exposed its full name on the price axis'); 'an unselected price level exposed its full name on the price axis');
await row.locator('.drawing-secondary>span').click(); await row.locator('.drawing-secondary>span').click();
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
'clicking drawing-list content selected the drawing');
await row.locator('.line-select').check();
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id); await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
assert.equal(await page.evaluate(levelId => assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), label, window.__chart.priceLines.get(levelId).line.options().title, id), label,
@ -461,9 +458,9 @@ test('trendline slope in the tooltip is signed points per hour',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
const labels = await page.evaluate(() => ({ const labels = await page.evaluate(() => ({
up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }), up: window.__chart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }), down: window.__chart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }), flat: window.__chart.slopeLabel({ kind: 'manual', slope: 0 }),
})); }));
assert.equal(labels.up, '+2.00 /h'); assert.equal(labels.up, '+2.00 /h');
assert.equal(labels.down, '-0.50 /h'); assert.equal(labels.down, '-0.50 /h');

View file

@ -52,6 +52,7 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) }, chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) },
indexAt: () => 0.5, indexAt: () => 0.5,
coordinateAtIndex: () => 123, coordinateAtIndex: () => 123,
timeScaleSlots: () => [],
}; };
const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call( const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call(
coordinateProbe, halfHour, coordinateProbe, halfHour,
@ -165,6 +166,10 @@ test('future 5m projections keep their slope after the settlement break',
slope: 6 / thirtyMinutes, cutoff_t: null, slope: 6 / thirtyMinutes, cutoff_t: null,
}; };
c.bars = displayed; c.bars = displayed;
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS },
(_, index) => displayed.at(-1).t + (index + 1) * fiveMinutes,
));
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', mode: 'source_tf',
series: { series: {
@ -218,16 +223,24 @@ test('daily future slots and live duration follow the server session calendar',
'1d', sunday, 23 * 3600, bars[0].t, thursday, [monday], [23 * 3600], '1d', sunday, 23 * 3600, bars[0].t, thursday, [monday], [23 * 3600],
); );
const source = c.trendlineSourceSeries.get('1d'); const source = c.trendlineSourceSeries.get('1d');
const horizonLine = {
kind: 'manual', tf: '1d', anchor_t: sunday, anchor_p: 100,
last_t: monday, slope: 1 / day,
};
return { return {
displayed: c.futureTimes(c.bars).map(point => point.time), displayed: c.futureTimes(c.bars).map(point => point.time),
duration: source.durations.at(-1), duration: source.durations.at(-1),
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1), beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
shifted: c.shiftDisplayedTime(thursday, 1),
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
}; };
}); });
assert.deepEqual(result.displayed, [1786917600, 1787004000]); assert.deepEqual(result.displayed, [1786917600, 1787004000]);
assert.equal(result.duration, 23 * 3600); assert.equal(result.duration, 23 * 3600);
assert.equal(result.beyondHorizon, null); assert.equal(result.beyondHorizon, null);
assert.equal(result.shifted, 1786917600);
assert.equal(result.shiftBeyondHorizon, null);
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
@ -322,6 +335,9 @@ test('a short final data gap does not kink a future trendline',
historicalSlots: (current.x - previous.x) / (future.x - current.x), historicalSlots: (current.x - previous.x) / (future.x - current.x),
missingSlotOwned: c.futureSpace.data() missingSlotOwned: c.futureSpace.data()
.some(point => Number(point.time) === previousTime + minute), .some(point => Number(point.time) === previousTime + minute),
overlayDelta: Math.abs(
c.coordinateAtTime(futureTime) - c.chart.timeScale().timeToCoordinate(futureTime),
),
}; };
}); });
@ -329,6 +345,8 @@ test('a short final data gap does not kink a future trendline',
`a nine-minute data gap occupied ${result.historicalSlots} slots`); `a nine-minute data gap occupied ${result.historicalSlots} slots`);
assert.equal(result.missingSlotOwned, true, assert.equal(result.missingSlotOwned, true,
'the invisible scale series discarded the missing intraday timestamp'); 'the invisible scale series discarded the missing intraday timestamp');
assert.ok(result.overlayDelta < 0.01,
`future overlay missed the chart coordinate by ${result.overlayDelta}px`);
assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9, assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9,
`line kinked from ${result.historicalSlope} to ${result.futureSlope}`); `line kinked from ${result.historicalSlope} to ${result.futureSlope}`);
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
@ -448,6 +466,78 @@ test('a manual line series owns its future 5m slots without an SVG projection',
}); });
}); });
test('hidden trendlines cannot steal hits and Fibonacci uses its visible rail',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const step = 300;
const bars = Array.from({ length: 30 }, (_, index) => ({
tf: '5m', t: 500000 + index * step, o: 100, h: 102, l: 98, c: 100,
v: 1, closed: true,
}));
c.setDisplayFutureTimes(Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step));
c.setBars(bars);
const hidden = {
id: 'hidden-line', kind: 'manual', tf: '5m', side: 'support', label: 'hidden',
anchor_t: bars[5].t, anchor_p: 99, last_t: bars[15].t, slope: 1 / 3000,
cutoff_t: null, hidden: true, color: '#65b7cf', line_width: 2, provisional: false,
};
c.syncLevels([hidden]);
const hiddenX = c.coordinateAtTime(bars[20].t);
const hiddenP = c.linePriceAtIndex(hidden, c.indexAt(bars[20].t));
const hiddenHit = c.hitTest({
point: { x: hiddenX, y: c.candles.priceToCoordinate(hiddenP) }, time: bars[20].t,
});
const fib = {
id: 'fib-hit', kind: 'fibonacci', tf: '5m', anchor_t: bars[12].t,
anchor_p: 99, last_t: bars[20].t, slope: 2 / (bars[20].t - bars[12].t),
hidden: false, color: '#c8992f', line_width: 2,
};
c.setFibs([fib]);
c.chart.timeScale().setVisibleRange({ from: bars[0].t, to: bars.at(-1).t });
const originX = c.coordinateAtTime(fib.anchor_t);
const levelY = c.candles.priceToCoordinate(ConfluenceChart.fibPrices(fib)[3].price);
const historicalHit = c.hitTest({ point: { x: originX - 20, y: levelY }, time: bars[10].t });
const visibleHit = c.hitTest({ point: { x: originX + 20, y: levelY }, time: bars[14].t });
c.setSelectedLine(fib.id);
const rail = {
x1: Number(c.lineHitTarget.getAttribute('x1')),
x2: Number(c.lineHitTarget.getAttribute('x2')),
y1: Number(c.lineHitTarget.getAttribute('y1')),
y2: Number(c.lineHitTarget.getAttribute('y2')),
};
const plot = c.plotCanvas().getBoundingClientRect();
const event = {
button: 0, clientX: plot.left + Math.min(rail.x1, plot.width - 20),
clientY: plot.top + (rail.y1 + rail.y2) / 2,
preventDefault() {}, stopPropagation() {}, currentTarget: { setPointerCapture() {} }, pointerId: 1,
};
const originalFib = { anchor_t: fib.anchor_t, anchor_p: fib.anchor_p };
c.startLineDrag(event);
const dragging = c.draggingLine?.id;
c.moveLine({
...event, clientX: event.clientX - 30, clientY: event.clientY + 20,
});
const fibMoved = fib.anchor_t !== originalFib.anchor_t || fib.anchor_p !== originalFib.anchor_p;
const movedFlag = c.draggingLine?.moved;
c.draggingLine = null;
c.detachPointers('line');
return { hiddenHit, historicalHit, visibleHit, rail, dragging, fibMoved, movedFlag };
});
assert.equal(result.hiddenHit, null, 'a hidden trendline stole chart selection');
assert.equal(result.historicalHit, null, 'Fibonacci selected left of its visible origin');
assert.equal(result.visibleHit, 'fib-hit');
assert.equal(result.rail.x1, result.rail.x2, 'Fibonacci body target is not its vertical rail');
assert.equal(result.dragging, 'fib-hit', 'Fibonacci rail did not start a body drag');
assert.equal(result.fibMoved, true,
`Fibonacci body drag did not move its anchors: ${JSON.stringify(result)}`);
assertNoPageErrors(page, assert);
});
});
test('higher-timeframe trendlines are dashed and twice their stored width', test('higher-timeframe trendlines are dashed and twice their stored width',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {

View file

@ -109,6 +109,79 @@ def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path):
assert hour.closed is False assert hour.closed is False
def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_slope", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen = {}
def evaluate(clusters, price, atr15, at, symbol, watched):
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base
asyncio.run(instance.on_bar(minute(base, 100.0)))
instance.rebuild_levels()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert seen["at"] == base + 60
assert seen["price"] == 101.0
assert 101.0 in seen["centers"]
assert 102.0 not in seen["centers"]
def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path, max_bars_per_tf=2)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_evicted", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen_members = []
def evaluate(clusters, price, atr15, at, symbol, watched):
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base + 60
asyncio.run(instance.on_bar(minute(base, 100.0)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
instance.rebuild_levels()
assert next(level for level in instance.levels if level.id == "ml_evicted").geometry_resolved
seen_members.clear()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert "ml_evicted" not in seen_members
def test_a_tripped_manual_alert_stays_disarmed_after_rebuild_and_restart(tmp_path): def test_a_tripped_manual_alert_stays_disarmed_after_rebuild_and_restart(tmp_path):
instance = runtime(tmp_path) instance = runtime(tmp_path)
instance.manual_lines.add( instance.manual_lines.add(