From 11508e5325b8ed2373b7583c9071ae2816e7421b Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sat, 15 Aug 2026 04:48:49 -0500 Subject: [PATCH] tweaks --- app/api/routes.py | 2 ++ static/app.js | 6 ++++-- static/chart.js | 25 +++++++++++++++++++++++-- static/index.html | 6 +++++- static/style.css | 2 +- tests/test_manual_lines.py | 24 ++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 6 deletions(-) diff --git a/app/api/routes.py b/app/api/routes.py index 2b2461a..62c92de 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -341,6 +341,8 @@ def patch_line(request: Request, line_id: str, payload: LinePatch): # None deliberately clears a per-level override and restores ATR behavior. if "alert_early_points" in payload.model_fields_set: changes["alert_early_points"] = payload.alert_early_points + if "cutoff_t" in payload.model_fields_set: + changes["cutoff_t"] = payload.cutoff_t if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes: raise HTTPException(400, "Line endpoints must have different times") try: diff --git a/static/app.js b/static/app.js index fb03437..077341c 100644 --- a/static/app.js +++ b/static/app.js @@ -131,6 +131,7 @@ createApp({ const comments = ref([]); const drawingFilter = ref(''); const drawingKind = ref('all'); + const drawingTf = ref('all'); const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const captureBusy = ref(false); const snap = ref(true); @@ -284,10 +285,11 @@ createApp({ const needle = drawingFilter.value.trim().toLowerCase(); return drawings.value.filter(item => { if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false; + const timeframe = item.line?.tf || item.comment?.tf || ''; + if (drawingTf.value !== 'all' && timeframe !== drawingTf.value) return false; if (!needle) return true; // Matched against what is on screen — the label or comment text, the // kind, and the drawing number, so "comment", "cpi" and "7" all work. - const timeframe = item.line?.tf || item.comment?.tf || ''; return `${item.label} ${item.kind} ${timeframe} #${item.number}`.toLowerCase().includes(needle); }); }); @@ -1223,6 +1225,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index eb9a6cb..0d0f50c 100644 --- a/static/chart.js +++ b/static/chart.js @@ -40,6 +40,7 @@ class ConfluenceChart { this.contextCutoff = null; this.contextLineId = null; this.contextEndButton = null; + this.contextExtendButton = null; this.contextListener = null; this.onLineEnd = null; this.onLineDuplicate = null; @@ -319,10 +320,18 @@ class ConfluenceChart { this.endSelectedLineHere(); }); this.contextEndButton = endHere; + const extend = document.createElement('button'); + extend.type = 'button'; + extend.textContent = 'Extend trendline'; + extend.addEventListener('click', event => { + event.stopPropagation(); + this.extendSelectedLine(); + }); + this.contextExtendButton = extend; const duplicate = document.createElement('button'); duplicate.type = 'button'; duplicate.dataset.action = 'duplicate'; - duplicate.textContent = 'Duplicate 10 bars right'; + duplicate.textContent = 'Duplicate'; duplicate.addEventListener('click', event => { event.stopPropagation(); const level = this.levels.find(value => value.id === this.contextLineId); @@ -340,7 +349,7 @@ class ConfluenceChart { if (id && this.onLineDelete) this.onLineDelete(id); }); this.contextMenu.addEventListener('click', event => event.stopPropagation()); - this.contextMenu.append(endHere, duplicate, deleteLine); + this.contextMenu.append(endHere, extend, duplicate, deleteLine); this.overlayLayer.appendChild(this.contextMenu); this.priceTag = document.createElement('div'); this.priceTag.className = 'chart-price-tag'; @@ -2200,6 +2209,7 @@ class ConfluenceChart { this.contextLineId = level.id; this.contextCutoff = cutoff > level.last_t ? cutoff : null; this.contextEndButton.hidden = this.contextCutoff == null; + this.contextExtendButton.hidden = level.cutoff_t == null; this.contextMenu.hidden = false; const plot = this.plotCanvas().getBoundingClientRect(); this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`; @@ -2229,6 +2239,17 @@ class ConfluenceChart { if (this.onLineEnd) this.onLineEnd({ ...level }); } + extendSelectedLine() { + const level = this.levels.find(value => value.id === this.selectedLineId); + if (!level || level.cutoff_t == null) return; + level.cutoff_t = null; + const entry = this.levelSeries.get(level.id); + if (entry) entry.series.setData(this.seriesLineData(level)); + this.renderLineBridges(); + this.hideContextMenu(); + if (this.onLineEnd) this.onLineEnd({ ...level }); + } + // Tinted by the bar's own direction, muted so the candles stay the subject. static toVolume(bar) { return { diff --git a/static/index.html b/static/index.html index 465d1f3..66a2897 100644 --- a/static/index.html +++ b/static/index.html @@ -231,7 +231,11 @@ - + +
diff --git a/static/style.css b/static/style.css index 81884fa..069349d 100644 --- a/static/style.css +++ b/static/style.css @@ -105,7 +105,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .option-row { display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:4px; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:10px; } .option-row.option-head { color:var(--muted); text-transform:uppercase; letter-spacing:.3px; font-size:8px; } .option-row button { padding:2px 6px; font-size:8px; } -.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; } +.drawing-filters { display:grid; grid-template-columns:auto auto 1fr; gap:6px; margin-bottom:8px; } .drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; } .drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; } diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index 81e4e33..d234852 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -136,3 +136,27 @@ def test_every_drawing_gets_a_number_including_comments(tmp_path): third = store.add(ManualLine(id="ml_c", slope=1.0, **common)) assert [first.number, note.number, third.number] == [1, 2, 3] + + +def test_a_null_cutoff_clears_an_ended_line(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.routes import router + from app.config import Settings + from app.runtime import Runtime + + app = FastAPI() + app.include_router(router) + app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json")) + client = TestClient(app) + created = client.post("/api/lines", json={ + "tf": "1m", "side": "support", + "anchor_t": 100, "anchor_p": 1.0, + "end_t": 200, "end_p": 2.0, + "cutoff_t": 150, + }).json() + assert created["cutoff_t"] == 150 + + cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json() + assert cleared["cutoff_t"] is None