fix future drawing interactions
This commit is contained in:
parent
90fc77df92
commit
5468bbec60
10 changed files with 365 additions and 59 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
static/chart.js
109
static/chart.js
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue