From 9777188a437b90c424ca16147a45f4b9a2478986 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sun, 9 Aug 2026 23:28:23 -0500 Subject: [PATCH] end trendline here possible --- app/analysis/confluence.py | 6 ++- app/analysis/levels.py | 1 + app/analysis/manual_lines.py | 3 ++ app/api/routes.py | 1 + static/app.js | 12 ++++++ static/chart.js | 79 +++++++++++++++++++++++++++++++++--- static/style.css | 1 + tests/test_confluence.py | 6 +++ 8 files changed, 103 insertions(+), 6 deletions(-) diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index ab5bec0..78ee959 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -30,7 +30,11 @@ def cluster_levels( if tolerance <= 0: return [] groups: list[list[tuple[float, Level]]] = [] - positioned = [(level.price_at(current_t), level) for level in levels if not level.hidden] + positioned = [ + (level.price_at(current_t), level) + for level in levels + if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t) + ] for positional_side in (Side.SUPPORT, Side.RESISTANCE): side_levels = sorted( ( diff --git a/app/analysis/levels.py b/app/analysis/levels.py index 4001741..7358796 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -39,6 +39,7 @@ class Level: color: str | None = None line_width: int | None = None number: int | None = None + cutoff_t: int | None = None def price_at(self, t: int) -> float: return self.anchor_p + self.slope * (t - self.anchor_t) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 7ba3c07..881d9a9 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -23,6 +23,7 @@ class ManualLine: color: str = "#65b7cf" line_width: int = 2 number: int = 0 + cutoff_t: int | None = None def to_level(self) -> Level: return Level( @@ -45,6 +46,7 @@ class ManualLine: color=self.color, line_width=self.line_width, number=self.number, + cutoff_t=self.cutoff_t, ) def to_dict(self) -> dict: @@ -69,6 +71,7 @@ class ManualLine: color=str(value.get("color", "#65b7cf")), line_width=int(value.get("line_width", 2)), number=int(value.get("number", 0)), + cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None, ) diff --git a/app/api/routes.py b/app/api/routes.py index ead70a6..e2dbe1a 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -34,6 +34,7 @@ class LinePatch(BaseModel): anchor_p: float | None = None slope: float | None = None last_t: int | None = None + cutoff_t: int | None = None @router.get("/health") diff --git a/static/app.js b/static/app.js index eb5d911..46000d7 100644 --- a/static/app.js +++ b/static/app.js @@ -223,6 +223,17 @@ createApp({ syncVisibleLevels(); } + async function endLineHere(line) { + const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cutoff_t: line.cutoff_t }), + }); + if (!response.ok) { console.error(`Unable to end line: HTTP ${response.status}`); return; } + const saved = await response.json(); + levels.value = levels.value.map(level => level.id === line.id ? saved : level); + syncVisibleLevels(); + } + function handleKeydown(event) { if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) { event.preventDefault(); @@ -279,6 +290,7 @@ createApp({ chartApi.setClickHandler(handleChartClick); chartApi.setMoveHandler(handleChartMove); chartApi.setLineChangeHandler(updateLineGeometry); + chartApi.setLineEndHandler(endLineHere); window.addEventListener('keydown', handleKeydown); refreshStatus(); connect(); diff --git a/static/chart.js b/static/chart.js index 0b45808..adde704 100644 --- a/static/chart.js +++ b/static/chart.js @@ -18,6 +18,10 @@ class ConfluenceChart { this.onLineChange = null; this.anchorMoveListener = null; this.anchorUpListener = null; + this.contextMenu = null; + this.contextCutoff = null; + this.contextListener = null; + this.onLineEnd = null; } create(el) { @@ -70,11 +74,27 @@ class ConfluenceChart { this.anchorHandles.push(handle); } el.appendChild(handles); + this.contextMenu = document.createElement('div'); + this.contextMenu.className = 'chart-context-menu'; + this.contextMenu.hidden = true; + const endHere = document.createElement('button'); + endHere.type = 'button'; + endHere.textContent = 'End trendline here'; + endHere.addEventListener('click', event => { + event.stopPropagation(); + this.endSelectedLineHere(); + }); + this.contextMenu.addEventListener('click', event => event.stopPropagation()); + this.contextMenu.appendChild(endHere); + el.appendChild(this.contextMenu); this.anchorMoveListener = event => this.moveAnchor(event); this.anchorUpListener = event => this.finishAnchorDrag(event); window.addEventListener('pointermove', this.anchorMoveListener); window.addEventListener('pointerup', this.anchorUpListener); + this.contextListener = event => this.showContextMenu(event); + el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { + this.hideContextMenu(); if (!this.onChartClick) return; const bounds = el.getBoundingClientRect(); const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; @@ -129,7 +149,7 @@ class ConfluenceChart { lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, - lastValueVisible: true, + lastValueVisible: level.cutoff_t == null, title: level.label, // Overlays outside the visible price range must not flatten the candles. autoscaleInfoProvider: () => null, @@ -143,6 +163,11 @@ class ConfluenceChart { let data; if (isMa) { data = (level.points || []).map(([time, value]) => ({ time, value })); + const latestTime = this.bars[this.bars.length - 1]?.t; + const latestValue = data[data.length - 1]?.value; + if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) { + data.push({ time: latestTime, value: latestValue }); + } } else { data = this.lineData(level); } @@ -157,8 +182,11 @@ class ConfluenceChart { setLineChangeHandler(handler) { this.onLineChange = handler; } + setLineEndHandler(handler) { this.onLineEnd = handler; } + setSelectedLine(id) { this.selectedLineId = id; + this.hideContextMenu(); this.renderAnchorHandles(); } @@ -236,11 +264,14 @@ class ConfluenceChart { ]; const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t); const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t); - const lastIndex = this.bars.length - 1; - if (anchorIndex >= 0 && secondIndex > anchorIndex && lastIndex > secondIndex) { + const cutoffIndex = level.cutoff_t == null + ? -1 + : this.bars.findIndex(bar => bar.t >= level.cutoff_t); + const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1; + if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) { const value = level.anchor_p - + (secondPrice - level.anchor_p) * (lastIndex - anchorIndex) / (secondIndex - anchorIndex); - points.push({ time: this.bars[lastIndex].t, value }); + + (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex); + points.push({ time: this.bars[targetIndex].t, value }); } return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time); } @@ -294,6 +325,7 @@ class ConfluenceChart { level.slope = (secondPrice - price) / (level.last_t - time); } else { if (time <= level.anchor_t) return; + if (level.cutoff_t != null && time >= level.cutoff_t) return; level.last_t = time; level.slope = (price - level.anchor_p) / (time - level.anchor_t); } @@ -310,6 +342,42 @@ class ConfluenceChart { if (level && this.onLineChange) this.onLineChange({ ...level }); } + showContextMenu(event) { + const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual'); + if (!level || !this.bars.length) return; + event.preventDefault(); + const bounds = this.chartEl.getBoundingClientRect(); + const x = event.clientX - bounds.left; + const rawTime = this.chart.timeScale().coordinateToTime(x); + if (rawTime == null) return; + const cutoff = this.bars.reduce((nearest, bar) => + Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest + ).t; + if (cutoff <= level.last_t) { + this.hideContextMenu(); + return; + } + this.contextCutoff = cutoff; + this.contextMenu.hidden = false; + this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`; + this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`; + } + + hideContextMenu() { + if (this.contextMenu) this.contextMenu.hidden = true; + this.contextCutoff = null; + } + + endSelectedLineHere() { + const level = this.levels.find(value => value.id === this.selectedLineId); + if (!level || this.contextCutoff == null) return; + level.cutoff_t = this.contextCutoff; + const entry = this.levelSeries.get(level.id); + if (entry) entry.series.setData(this.lineData(level)); + this.hideContextMenu(); + if (this.onLineEnd) this.onLineEnd({ ...level }); + } + toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; } @@ -317,6 +385,7 @@ class ConfluenceChart { destroy() { if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); + if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener); if (this.chart) this.chart.remove(); diff --git a/static/style.css b/static/style.css index 94628fb..cdd5b55 100644 --- a/static/style.css +++ b/static/style.css @@ -14,6 +14,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } +.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } diff --git a/tests/test_confluence.py b/tests/test_confluence.py index 5ca1643..d68a69c 100644 --- a/tests/test_confluence.py +++ b/tests/test_confluence.py @@ -31,3 +31,9 @@ def test_lone_daily_level_is_emitted(): def test_levels_on_opposite_sides_of_price_do_not_cluster(): clusters = cluster_levels([level("below", 99.9, 2), level("above", 100.1, 2)], 200, 100, 1) assert clusters == [] + + +def test_level_ended_before_current_time_is_excluded(): + ended = level("ended", 98, 12, Timeframe.D1) + ended.cutoff_t = 150 + assert cluster_levels([ended], 200, 100, 1) == []