From bc9fed764a9fd5e1c736d12b36a4dddb297a7683 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 1 Sep 2026 22:39:37 -0500 Subject: [PATCH] Show session high/low as short ticks at the live edge. Forming daily H/L, not a full-width line. Config default on. A forming tick only moves the marks if the range actually expanded. --- app/api/ws.py | 23 +++++++++ static/app.js | 17 ++++++- static/chart.js | 98 ++++++++++++++++++++++++++++++++++++ static/index.html | 1 + static/style.css | 1 + tests/test_ws_preferences.py | 13 +++++ 6 files changed, 152 insertions(+), 1 deletion(-) diff --git a/app/api/ws.py b/app/api/ws.py index 8f70030..c11cb6b 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -54,6 +54,15 @@ def session_open(runtime) -> float | None: return bars[-1].o if bars else None +def session_range(runtime) -> dict | None: + """High/low of the forming CME session — today's range, not prior day.""" + bars = runtime.store.get(Timeframe.D1, 1) + if not bars: + return None + bar = bars[-1] + return {"t": bar.t, "high": bar.h, "low": bar.l} + + def trendline_timeframes(runtime) -> set[Timeframe]: return { level.tf for level in runtime.levels @@ -110,10 +119,17 @@ def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> source_bars[source_index - 1].t if source_index is not None and source_index > 0 else None ) + rng = session_range(runtime) + extra = { + "session_t": rng["t"], + "session_high": rng["high"], + "session_low": rng["low"], + } if rng else {} payload.update( { "duration": bucket_duration(bar.t, bar.tf), "session_open": session_open(runtime), + **extra, "trendline_first_t": ( first_source_t if runtime.settings.trendline_source_geometry @@ -156,6 +172,12 @@ def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: events, events_more = runtime.events.recent() + rng = session_range(runtime) + extra = { + "session_t": rng["t"], + "session_high": rng["high"], + "session_low": rng["low"], + } if rng else {} return { "type": "snapshot", "tf": tf.value, @@ -166,6 +188,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: if runtime.store.get(Timeframe.M1, 1) else None, "session_open": session_open(runtime), + **extra, "trendline_geometry": trendline_geometry(runtime), "future_times": displayed_future_times(runtime, tf), "events": events, diff --git a/static/app.js b/static/app.js index b407c47..f9fed63 100644 --- a/static/app.js +++ b/static/app.js @@ -154,6 +154,7 @@ createApp({ const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false'); const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true'); + const sessionRange = ref(localStorage.getItem('chart-session-range') !== 'false'); const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false'); const optionPrefs = (() => { try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; } @@ -535,6 +536,9 @@ createApp({ drawColorInitialized = true; } syncVisibleLevels(); + chartApi.setSessionRange({ + t: message.session_t, high: message.session_high, low: message.session_low, + }); price.value = message.price; sessionOpen.value = message.session_open ?? sessionOpen.value; hydrateEvents(message.events, message.events_more); @@ -560,6 +564,11 @@ createApp({ } } if (message.future_times) chartApi.setDisplayFutureTimes(message.future_times); + if (message.session_t != null) { + chartApi.setSessionRange({ + t: message.session_t, high: message.session_high, low: message.session_low, + }); + } chartApi.updateBar(message.bar); noteLivePrice(message.bar.c); if (message.session_open != null) sessionOpen.value = message.session_open; @@ -1627,6 +1636,11 @@ createApp({ if (chartApi) chartApi.setAutoScrollLivePrice(value); }); + watch(sessionRange, value => { + localStorage.setItem('chart-session-range', String(value)); + if (chartApi) chartApi.setSessionRangeEnabled(value); + }); + watch(extraDetail, value => { localStorage.setItem('chart-extra-detail', String(value)); }); @@ -1658,6 +1672,7 @@ createApp({ chartApi.setSnap(snap.value); chartApi.setCurrentPriceAnimation(animateCurrentPrice.value); chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value); + chartApi.setSessionRangeEnabled(sessionRange.value); chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment }); window.addEventListener('keydown', handleKeydown); nextTick(() => { @@ -1682,6 +1697,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, confluenceAlerts, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, selectedTrendline, duplicateSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, 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, canUndo, undoTitle, undo }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, sessionRange, hideLowerTfDrawings, confluenceAlerts, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, selectedTrendline, duplicateSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, 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, canUndo, undoTitle, undo }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index b428759..17f1cda 100644 --- a/static/chart.js +++ b/static/chart.js @@ -274,6 +274,11 @@ class ConfluenceChart { this.commentNodes = new Map(); this.commentLayer = null; this.contextLabelLayer = null; + this.sessionRangeLayer = null; + this.sessionRangeEnabled = true; + this.sessionT = null; + this.sessionHigh = null; + this.sessionLow = null; this.overlayFrame = null; this.overlayForce = false; this.overlayRangeKey = ''; @@ -485,6 +490,9 @@ class ConfluenceChart { this.contextLabelLayer = document.createElement('div'); this.contextLabelLayer.className = 'chart-context-labels'; this.overlayLayer.appendChild(this.contextLabelLayer); + this.sessionRangeLayer = document.createElement('div'); + this.sessionRangeLayer.className = 'session-range-marks'; + this.overlayLayer.appendChild(this.sessionRangeLayer); this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); @@ -848,11 +856,100 @@ class ConfluenceChart { this.volume.update(ConfluenceChart.toVolume(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); + this.expandSessionRange(bar); if (advanced) this.extendManualLineSeries(last, bar); if (advanced) this.syncRthLines(); if (advanced) this.scheduleOverlays(true); } + setSessionRangeEnabled(enabled) { + this.sessionRangeEnabled = Boolean(enabled); + this.renderSessionRangeMarks(); + } + + setSessionRange(range) { + if (!range || range.t == null || range.high == null || range.low == null) return; + const rolled = this.sessionT == null || range.t !== this.sessionT; + this.sessionT = range.t; + if (rolled) { + this.sessionHigh = range.high; + this.sessionLow = range.low; + } else { + this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high); + this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low); + } + this.syncSessionRangeMarks(); + } + + expandSessionRange(bar) { + if (!this.sessionRangeEnabled || this.bars[0]?.tf === '1d') return; + if (this.sessionT == null || bar.t < this.sessionT) return; + let changed = false; + if (bar.h != null && (this.sessionHigh == null || bar.h > this.sessionHigh)) { + this.sessionHigh = bar.h; + changed = true; + } + if (bar.l != null && (this.sessionLow == null || bar.l < this.sessionLow)) { + this.sessionLow = bar.l; + changed = true; + } + if (changed) this.syncSessionRangeMarks(); + } + + renderSessionRangeMarks() { + if (!this.sessionRangeLayer) return; + const show = this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d' + && this.sessionHigh != null && this.sessionLow != null; + if (!show) { + this.sessionRangeLayer.replaceChildren(); + return; + } + const marks = [ + { key: 'high', label: 'H', value: this.sessionHigh }, + { key: 'low', label: 'L', value: this.sessionLow }, + ].map(mark => { + const node = document.createElement('div'); + node.className = `session-range-mark ${mark.key}`; + node.dataset.key = mark.key; + const text = document.createElement('span'); + text.textContent = `${mark.label} ${Number(mark.value).toFixed(2)}`; + node.appendChild(text); + return node; + }); + this.sessionRangeLayer.replaceChildren(...marks); + this.syncSessionRangeMarks(); + } + + syncSessionRangeMarks() { + if (!this.sessionRangeLayer || !this.candles) return; + if (!this.sessionRangeLayer.children.length) { + if (this.sessionRangeEnabled && this.sessionHigh != null) this.renderSessionRangeMarks(); + return; + } + const height = this.plotCanvas()?.getBoundingClientRect().height || 0; + const place = (key, value) => { + const node = this.sessionRangeLayer.querySelector(`[data-key="${key}"]`); + if (!node) return null; + const y = value == null ? null : this.candles.priceToCoordinate(value); + const on = y != null && y >= 0 && y <= height; + node.hidden = !on; + if (on) node.style.top = `${Math.round(y)}px`; + const label = node.querySelector('span'); + if (label && value != null) { + label.textContent = `${key === 'high' ? 'H' : 'L'} ${Number(value).toFixed(2)}`; + } + return on ? y : null; + }; + const highY = place('high', this.sessionHigh); + const lowY = place('low', this.sessionLow); + if (highY != null && lowY != null && Math.abs(highY - lowY) < 14) { + const high = this.sessionRangeLayer.querySelector('[data-key="high"]'); + const low = this.sessionRangeLayer.querySelector('[data-key="low"]'); + if (high) high.style.top = `${Math.round(highY - 7)}px`; + if (low) low.style.top = `${Math.round(lowY + 7)}px`; + } + } + extendManualLineSeries(previous, bar) { const step = ConfluenceChart.barInterval(this.bars); if (!(step > 0)) return; @@ -1546,6 +1643,7 @@ class ConfluenceChart { this.renderProjectionDiagnostic(); this.renderComments(); this.renderContextLabels(); + this.renderSessionRangeMarks(); this.syncCurrentPricePulse(); }); } diff --git a/static/index.html b/static/index.html index b21ba99..801e7e0 100644 --- a/static/index.html +++ b/static/index.html @@ -74,6 +74,7 @@