This commit is contained in:
Chris Amow 2026-08-15 04:48:49 -05:00
parent 3adb98c1fe
commit 11508e5325
6 changed files with 59 additions and 6 deletions

View file

@ -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:

View file

@ -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');

View file

@ -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 {

View file

@ -231,7 +231,11 @@
<option value="comment">Comments</option>
<option value="symbol">Symbols</option>
</select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
<select v-model="drawingTf" aria-label="Filter drawings by timeframe">
<option value="all">All TFs</option>
<option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</option>
</select>
<input v-model="drawingFilter" placeholder="Filter text" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>

View file

@ -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; }

View file

@ -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