diff --git a/AGENTS.md b/AGENTS.md index 8240836..08d9193 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,9 +120,9 @@ geometry change that needs explicit browser verification. - The displayed time scale, a drawing's source-timeframe bar space, and the server session calendar are different coordinate systems. Never substitute wall-clock seconds or the displayed grid for canonical source geometry. -- Daily future slots must skip non-session days. Intraday future slots cross the - settlement break and weekend today; an endpoint that looks valid before the - break can become unresolved when real history arrives. +- Daily and intraday future slots must skip non-session time. An endpoint that + looks valid before a settlement/weekend break must remain resolvable when real + history arrives. - 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. - DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating diff --git a/app/runtime.py b/app/runtime.py index de3bf6c..b3000d2 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -100,7 +100,7 @@ class Runtime: self.broadcast({"type": "bar", "bar": provisional}) return - evaluate_alerts = False + alert_bar: Bar | None = None for aggregated in self.aggregator.update(bar): self.store.put(aggregated) if self.seeding: @@ -116,14 +116,18 @@ class Runtime: self.request_rebuild() if aggregated.tf is Timeframe.M1 and aggregated.closed: self.price = aggregated.c - evaluate_alerts = True - if evaluate_alerts: + alert_bar = aggregated + if alert_bar is not None: values = atr(self.store.get(Timeframe.M15), 14) 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 # broadcast is a delta, which is what keeps that affordable. 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]: """Higher-timeframe bars including the minute still being traded. @@ -209,7 +213,9 @@ class Runtime: self.broadcast_level_delta() 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. The chart spaces bars evenly, so the line a person drew advances per bar. @@ -219,7 +225,7 @@ class Runtime: if not bars: return 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: if not level.slope: continue @@ -245,7 +251,12 @@ class Runtime: if changed or 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: return 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 # layer preferences: those are a display choice made in one browser, # 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.alert_engine.evaluate( - self.clusters, - self.price, + alert_clusters, + evaluation_price, self.atr15, - self.stream.last_bar_t, + evaluation_t, self.stream.symbol, self.watched_ma_levels(), ) diff --git a/docs/implementation.md b/docs/implementation.md index 8dd7948..f33c8f3 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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, the shared horizon, display payload, daily live duration, beyond-horizon refusal, 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. diff --git a/static/app.js b/static/app.js index de48a7c..9886583 100644 --- a/static/app.js +++ b/static/app.js @@ -1054,7 +1054,7 @@ createApp({ } function shiftTimeByBars(line, time, count = 10) { - return chartApi?.shiftLineTime(line, time, count) ?? time; + return chartApi?.shiftLineTime(line, time, count) ?? null; } function duplicateName(line) { @@ -1070,6 +1070,8 @@ createApp({ if (line.geometry_resolved === false) return; const anchorT = shiftTimeByBars(line, line.anchor_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 response = await apiFetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1079,7 +1081,7 @@ createApp({ end_t: endT, end_p: endP, note: duplicateName(line), hidden: line.hidden, 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, }), }); @@ -1255,10 +1257,15 @@ createApp({ + vertical * ConfluenceChart.TICK; if (horizontal) { if (line.geometry_resolved === false) continue; - line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal); - line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal); + const anchorT = chartApi.shiftLineTime(line, line.anchor_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) { - 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); @@ -1274,9 +1281,7 @@ createApp({ ); } if (horizontal) { - changes.anchor_t = chartApi.timeAtIndex( - chartApi.indexAt(comment.anchor_t) + horizontal, - ); + changes.anchor_t = chartApi.shiftDisplayedTime(comment.anchor_t, horizontal); } if (Object.keys(changes).length) await updateCommentGeometry(comment, changes); } else { @@ -1285,9 +1290,9 @@ createApp({ const clamp = value => Math.min(Math.max(value, 0), 1); await updateCommentGeometry(comment, { 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) - / chartApi.chartEl.clientHeight), + / chartApi.overlayLayer.clientHeight), }); } } diff --git a/static/chart.js b/static/chart.js index 5817402..a0e0901 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1079,8 +1079,8 @@ class ConfluenceChart { // and a floating one must ignore the time scale entirely. renderComments() { if (!this.commentLayer) return; - const width = this.chartEl.clientWidth; - const height = this.chartEl.clientHeight; + const width = this.overlayLayer.clientWidth; + const height = this.overlayLayer.clientHeight; const seen = new Set(); for (const comment of this.comments || []) { seen.add(comment.id); @@ -1115,8 +1115,10 @@ class ConfluenceChart { if (!comment.pinned) { // A fraction of the pane, so it holds its place through any zoom. - node.root.style.left = `${Math.round(comment.x * width)}px`; - node.root.style.top = `${Math.round(comment.y * height)}px`; + const left = Math.min(Math.max(comment.x * width, 0), Math.max(0, width - node.root.offsetWidth)); + 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'); continue; } @@ -1242,8 +1244,8 @@ class ConfluenceChart { } return; } - const x = (upEvent.clientX - bounds.left - 8) / bounds.width; - const y = (upEvent.clientY - bounds.top - 8) / bounds.height; + const x = (upEvent.clientX - bounds.left - root.offsetWidth / 2) / bounds.width; + const y = (upEvent.clientY - bounds.top - root.offsetHeight / 2) / bounds.height; const clamp = value => Math.min(Math.max(value, 0), 1); if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) }); }; @@ -1660,6 +1662,11 @@ class ConfluenceChart { const scale = this.chart.timeScale(); if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t); 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 upper = lower + 1; const x1 = scale.timeToCoordinate(this.bars[lower].t); @@ -1670,6 +1677,9 @@ class ConfluenceChart { coordinateAtTime(time) { 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 // shared scale may contain foreign timestamps from other series, and its // 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); 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 naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); 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 }; } 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)) { const y = this.candles.priceToCoordinate(level.price); const distance = y == null ? Infinity : Math.abs(y - param.point.y); @@ -2302,11 +2315,13 @@ class ConfluenceChart { } const last = bars.length - 1; if (time >= bars[last].t) { - // Future slots use the median interval, not the tail gap. After the CME - // settlement break the final gap is 65 minutes, and using it here makes a - // 5m future projection advance thirteen bars per visible slot. - const step = ConfluenceChart.barInterval(bars); - return last + (step ? (time - bars[last].t) / step : 0); + const future = this.futureTimes(bars).map(point => point.time); + if (!future.length) return last; + let lower = -1; + while (lower + 1 < future.length && future[lower + 1] <= time) lower += 1; + 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 hi = last; @@ -2394,9 +2409,18 @@ class ConfluenceChart { shiftLineTime(level, time, bars) { 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); - 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) { @@ -2405,8 +2429,15 @@ class ConfluenceChart { if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t)); const last = bars.length - 1; if (index >= last) { - const step = ConfluenceChart.barInterval(bars); - return Math.round(bars[last].t + (index - last) * step); + const offset = index - last; + 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 fraction = index - lower; @@ -2678,6 +2709,34 @@ class ConfluenceChart { 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 naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd); @@ -2730,7 +2789,7 @@ class ConfluenceChart { startLineDrag(event) { 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); if (!level || level.geometry_resolved === false || !point || point.t == null) return; event.preventDefault(); @@ -2748,7 +2807,7 @@ class ConfluenceChart { moveLine(event) { if (!this.draggingLine) return; 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; event.preventDefault(); const { original, start } = this.draggingLine; @@ -2767,6 +2826,13 @@ class ConfluenceChart { const lastT = source ? this.shiftLineTime(original, 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 + original.slope * (original.last_t - original.anchor_t) + priceShift; level.anchor_t = anchorT; @@ -2774,13 +2840,12 @@ class ConfluenceChart { level.anchor_p = original.anchor_p + priceShift; level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); if (original.cutoff_t != null) { - level.cutoff_t = source - ? this.shiftLineTime(original, original.cutoff_t, indexShift) - : this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); + level.cutoff_t = cutoffT; } this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); + if (level.kind === 'fibonacci') this.syncFibLines(); this.renderAnchorHandles(); this.renderLineBridges(); } @@ -2788,7 +2853,7 @@ class ConfluenceChart { finishLineDrag(event) { if (!this.draggingLine) return; 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 original = this.draggingLine.original; this.draggingLine = null; diff --git a/tests/e2e/chart.test.mjs b/tests/e2e/chart.test.mjs index 8f67480..c0f45b7 100644 --- a/tests/e2e/chart.test.mjs +++ b/tests/e2e/chart.test.mjs @@ -167,7 +167,7 @@ test('the status row shows the exact local time data last arrived', return freshness?.textContent.trim() !== '—'; }); const state = await page.evaluate(() => { - const freshness = document.querySelector('.data-freshness'); + const freshness = document.querySelector('.data-freshness span:last-child'); return { freshness: freshness.textContent.trim(), 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.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.priceLineVisible, false); assert.equal(state.markerOptions.lineWidth, 1); @@ -219,7 +219,7 @@ test('a daily crosshair shows the session date, not the previous evening', return { actual: c.chart.options().localization.timeFormatter(time), 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', }), }; }); diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index 7810a98..42827ca 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -53,6 +53,9 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as }, TEXT); 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.locator('.trendline-row .line-select').check(); await page.keyboard.press('ArrowUp'); @@ -88,11 +91,8 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => { assert.deepEqual(controls, ['collapse-toggle', 'color'], 'the comment color picker is not the rightmost control'); 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, - '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'); assert.equal(await row.evaluate(node => node.classList.contains('selected')), false, '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 () => { await withChart(async page => { const box = await chartBox(page); diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs index 5c2d70a..4728a81 100644 --- a/tests/e2e/drawings.test.mjs +++ b/tests/e2e/drawings.test.mjs @@ -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'); assert.equal(await page.evaluate(() => 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 => window.__chart.levels.find(level => level.label === name)?.id || null, label); 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'); 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); assert.equal(await page.evaluate(levelId => 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 () => { await withChart(async page => { const labels = await page.evaluate(() => ({ - up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }), - down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }), - flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }), + up: window.__chart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }), + down: window.__chart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }), + flat: window.__chart.slopeLabel({ kind: 'manual', slope: 0 }), })); assert.equal(labels.up, '+2.00 /h'); assert.equal(labels.down, '-0.50 /h'); diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 5214768..7ddfa83 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -52,6 +52,7 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) }, indexAt: () => 0.5, coordinateAtIndex: () => 123, + timeScaleSlots: () => [], }; const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call( coordinateProbe, halfHour, @@ -165,6 +166,10 @@ test('future 5m projections keep their slope after the settlement break', slope: 6 / thirtyMinutes, cutoff_t: null, }; c.bars = displayed; + c.setDisplayFutureTimes(Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, + (_, index) => displayed.at(-1).t + (index + 1) * fiveMinutes, + )); c.setTrendlineGeometry({ mode: 'source_tf', 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], ); 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 { displayed: c.futureTimes(c.bars).map(point => point.time), duration: source.durations.at(-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.equal(result.duration, 23 * 3600); assert.equal(result.beyondHorizon, null); + assert.equal(result.shifted, 1786917600); + assert.equal(result.shiftBeyondHorizon, null); 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), missingSlotOwned: c.futureSpace.data() .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`); assert.equal(result.missingSlotOwned, true, '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, `line kinked from ${result.historicalSlope} to ${result.futureSlope}`); 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', { timeout: 180000 }, async () => { await withChart(async page => { diff --git a/tests/test_runtime_alerts.py b/tests/test_runtime_alerts.py index d075029..0e3088c 100644 --- a/tests/test_runtime_alerts.py +++ b/tests/test_runtime_alerts.py @@ -109,6 +109,79 @@ def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path): 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): instance = runtime(tmp_path) instance.manual_lines.add(