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.
This commit is contained in:
Chris Amow 2026-09-01 22:39:37 -05:00
parent 2fbb5957f3
commit bc9fed764a
6 changed files with 152 additions and 1 deletions

View file

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

View file

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

View file

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

View file

@ -74,6 +74,7 @@
</details>
<details class="sidebar-section config-section">
<summary>Config</summary>
<label title="Today's session high and low as short ticks at the live edge"><input type="checkbox" v-model="sessionRange"> Session high / low</label>
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>

View file

@ -118,5 +118,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
.session-range-marks { position:absolute; inset:0; z-index:5; overflow:visible; pointer-events:none; }.session-range-mark { position:absolute; right:0; transform:translateY(-50%); display:flex; align-items:center; gap:5px; }.session-range-mark span { padding:1px 5px; border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); font-size:9px; line-height:1.25; white-space:nowrap; }.session-range-mark::after { content:''; width:22px; border-top:1px dashed currentColor; }.session-range-mark.high { color:var(--green); }.session-range-mark.low { color:var(--red); }.session-range-mark[hidden] { display:none; }
.event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; }
.cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; }

View file

@ -84,6 +84,18 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path):
assert message["session_open"] == 6123.25
def test_snapshot_session_range_uses_the_forming_daily_bar(tmp_path):
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
runtime.store.put(Bar(Timeframe.D1, 1000, 100, 110, 90, 105, 1, True, "/ES", "test"))
runtime.store.put(Bar(Timeframe.D1, 2000, 105, 125, 102, 120, 1, False, "/ES", "test"))
message = snapshot(runtime, Timeframe.M1)
assert message["session_t"] == 2000
assert message["session_high"] == 125
assert message["session_low"] == 102
def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
@ -158,6 +170,7 @@ def test_a_forming_tick_does_not_resend_future_times(tmp_path):
assert tick["type"] == "bar"
assert "future_times" not in tick
assert "trendline_future_times" not in tick
assert "session_high" not in tick
higher = Bar(Timeframe.M30, 1800, 1, 2, 1, 1.5, 1, False, "/ES", "test")
runtime.broadcast({"type": "bar", "bar": higher})