diff --git a/docs/implementation.md b/docs/implementation.md index 23c6652..a2a4f79 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1175,3 +1175,17 @@ hours so a new refresh token is written to the volume. Status grows `needs_login` when the error is a dead grant; the header then offers **reconnect**, which starts the existing `/api/qt` flow and writes the token on callback instead of asking anyone to paste a URL into a prompt. + +### 2026-08-18 — SPY open/close + +Intraday charts can draw faint vertical lines at 09:30 and 16:00 ET on +weekdays — cash hours, not the CME session. They are a Lightweight Charts +series primitive on the candle pane (`attachPrimitive`), the same extension +point as `createPriceLine`, not another DOM overlay. Off on the daily chart. +Layer toggle **SPY open/close**, on by default. + +### 2026-08-18 — Trendline slope in the tooltip + +Hovering a sloped line now shows signed points per hour (`+1.25 /h`, +`-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe. +The stored slope is still price per second. Flat levels omit the line. diff --git a/static/app.js b/static/app.js index 7d123f6..15b5426 100644 --- a/static/app.js +++ b/static/app.js @@ -69,7 +69,7 @@ async function apiFetch(url, options = {}) { const defaultPrefs = { base_tf: '1m', - enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, drawings: true, manual: true, auto: false, horizontal: true, vwap: true }, + enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, drawings: true, manual: true, auto: false, horizontal: true, vwap: true, rth: true }, hidden_levels_score: false, }; @@ -1155,6 +1155,7 @@ createApp({ localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value)); syncVisibleLevels(); syncComments(); + if (chartApi) chartApi.setRthEnabled(prefs.value.enabled.rth); sendPrefs(); }, { deep: true }); @@ -1225,6 +1226,7 @@ createApp({ // guessed at: __chart.lineData(level) against timeToCoordinate(). window.__chart = chartApi; chartApi.create(document.getElementById('chart')); + chartApi.setRthEnabled(prefs.value.enabled.rth); chartApi.setClickHandler(handleChartClick); chartApi.setToolCompleteHandler(handleToolComplete); chartApi.setLineChangeHandler(line => { diff --git a/static/chart.js b/static/chart.js index 75521f4..70cd7aa 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1,3 +1,77 @@ +// Vertical session lines via Lightweight Charts' series primitive — canvas, +// not a DOM overlay. createPriceLine is horizontal only; attachPrimitive is +// the matching hook for a time. +class RthLinesPrimitive { + constructor() { + this._times = []; + this._styles = []; + this._xs = []; + this._chart = null; + this._requestUpdate = null; + this._view = { + update: () => this._sync(), + renderer: () => this._renderer, + }; + this._renderer = { draw: target => this._draw(target) }; + } + + attached(param) { + this._chart = param.chart; + this._requestUpdate = param.requestUpdate; + } + + detached() { + this._chart = null; + this._requestUpdate = null; + } + + setMarks(marks) { + this._times = marks.map(mark => mark.t); + this._styles = marks.map(mark => mark.kind); + if (this._requestUpdate) this._requestUpdate(); + } + + updateAllViews() { + this._view.update(); + } + + paneViews() { + return [this._view]; + } + + _sync() { + if (!this._chart) { + this._xs = []; + return; + } + const scale = this._chart.timeScale(); + this._xs = this._times.map(time => scale.timeToCoordinate(time)); + } + + _draw(target) { + if (!this._xs.length) return; + target.useBitmapCoordinateSpace(scope => { + const ctx = scope.context; + const height = scope.bitmapSize.height; + const width = Math.max(1, Math.round(scope.horizontalPixelRatio)); + ctx.save(); + for (let i = 0; i < this._xs.length; i += 1) { + const x = this._xs[i]; + if (x == null) continue; + const px = Math.round(x * scope.horizontalPixelRatio); + ctx.strokeStyle = 'rgba(44,41,36,0.20)'; + ctx.lineWidth = width; + ctx.setLineDash(this._styles[i] === 'close' ? [3 * width, 4 * width] : []); + ctx.beginPath(); + ctx.moveTo(px + 0.5, 0); + ctx.lineTo(px + 0.5, height); + ctx.stroke(); + } + ctx.restore(); + }); + } +} + class ConfluenceChart { constructor() { this.chart = null; @@ -58,6 +132,8 @@ class ConfluenceChart { this.toolUpListener = null; this.pendingView = null; this.overlayLayer = null; + this.rthPrimitive = null; + this.rthEnabled = true; this.futureSpace = null; this.snapDot = null; this.snapLabel = null; @@ -246,6 +322,8 @@ class ConfluenceChart { // blank — you could watch two trendlines converge and not know when. These // points carry no value and draw nothing; they exist so the axis has // something to label and so timeToCoordinate answers out there. + this.rthPrimitive = new RthLinesPrimitive(); + this.candles.attachPrimitive(this.rthPrimitive); this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, { priceScaleId: 'right', color: 'rgba(0,0,0,0)', @@ -460,6 +538,7 @@ class ConfluenceChart { }; this.chart.timeScale().setVisibleRange(this.pendingView); } + this.syncRthLines(); this.scheduleOverlays(true); } @@ -577,6 +656,7 @@ class ConfluenceChart { 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); if (advanced) this.extendManualLineSeries(last, bar); + if (advanced) this.syncRthLines(); this.scheduleOverlays(true); } @@ -1864,6 +1944,7 @@ class ConfluenceChart { const color = this.describeDrawingColor?.(line.color); this.tooltip.textContent = [ `#${line.number} ${line.label}`, + ConfluenceChart.slopeLabel(line), color?.name, color?.annotation, ].filter(Boolean).join('\n'); @@ -2509,6 +2590,110 @@ class ConfluenceChart { }; } + setRthEnabled(enabled) { + this.rthEnabled = enabled; + this.syncRthLines(); + } + + syncRthLines() { + if (!this.rthPrimitive) return; + if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') { + this.rthPrimitive.setMarks([]); + return; + } + const first = this.bars[0].t; + const last = this.bars[this.bars.length - 1].t; + const marks = []; + const seen = new Set(); + for (const mark of ConfluenceChart.rthInstants(first, last)) { + const time = this.barContaining(mark.t); + if (time == null || seen.has(time)) continue; + seen.add(time); + marks.push({ t: time, kind: mark.kind }); + } + this.rthPrimitive.setMarks(marks); + } + + barContaining(time) { + const bars = this.bars; + if (!bars.length || time < bars[0].t) return null; + let lo = 0; + let hi = bars.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (bars[mid].t <= time) lo = mid; + else hi = mid - 1; + } + return bars[lo].t; + } + + static easternParts(epochSec) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).formatToParts(new Date(epochSec * 1000)) + .filter(part => part.type !== 'literal') + .map(part => [part.type, part.value]), + ); + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour) % 24, + minute: Number(parts.minute), + }; + } + + static epochAtEastern(year, month, day, hour, minute) { + let guess = Date.UTC(year, month - 1, day, hour + 4, minute); + for (let i = 0; i < 4; i += 1) { + const got = ConfluenceChart.easternParts(guess / 1000); + const delta = Date.UTC(year, month - 1, day, hour, minute) + - Date.UTC(got.year, got.month - 1, got.day, got.hour, got.minute); + if (delta === 0) break; + guess += delta; + } + return Math.floor(guess / 1000); + } + + static rthInstants(fromT, toT) { + const marks = []; + const seen = new Set(); + let cursor = fromT - 12 * 3600; + const stop = toT + 12 * 3600; + while (cursor <= stop) { + const parts = ConfluenceChart.easternParts(cursor); + const key = `${parts.year}-${parts.month}-${parts.day}`; + const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay(); + if (!seen.has(key) && weekday !== 0 && weekday !== 6) { + seen.add(key); + marks.push({ + t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 9, 30), + kind: 'open', + }); + marks.push({ + t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 16, 0), + kind: 'close', + }); + } + cursor += 12 * 3600; + } + return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600); + } + + static slopeLabel(line) { + if (ConfluenceChart.isFlat(line) || !line.slope) return ''; + const perHour = line.slope * 3600; + const decimals = Math.abs(perHour) >= 10 ? 1 : 2; + return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`; + } + toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; } diff --git a/static/index.html b/static/index.html index f0f708c..2c715a7 100644 --- a/static/index.html +++ b/static/index.html @@ -48,6 +48,7 @@
+
diff --git a/static/style.css b/static/style.css index d1d924d..b758efa 100644 --- a/static/style.css +++ b/static/style.css @@ -36,7 +36,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .color-popover { grid-template-columns:1fr; width:178px; gap:3px; } .color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); } .level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; } -.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:grid; grid-template-columns:repeat(3, max-content); gap:7px 10px; padding-left:20px; }.periods .period { display:flex; align-items:center; gap:2px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; } +.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:grid; grid-template-columns:repeat(3, max-content); gap:7px 10px; padding-left:20px; }.periods .period { display:flex; align-items:center; gap:2px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; } /* Tool palette: the head arms the tool, the body configures what it creates. */ diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs index b78d150..28d6250 100644 --- a/tests/e2e/drawings.test.mjs +++ b/tests/e2e/drawings.test.mjs @@ -293,3 +293,18 @@ test('filtered selections can hide and show drawings without deleting them', assertNoPageErrors(page, assert); }); }); + +test('trendline slope in the tooltip is signed points per hour', + { timeout: 180000 }, async () => { + await withChart(async page => { + const labels = await page.evaluate(() => ({ + up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }), + down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }), + flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }), + })); + assert.equal(labels.up, '+2.00 /h'); + assert.equal(labels.down, '-0.50 /h'); + assert.equal(labels.flat, ''); + assertNoPageErrors(page, assert); + }); + }); diff --git a/tests/e2e/rth.test.mjs b/tests/e2e/rth.test.mjs new file mode 100644 index 0000000..48a463c --- /dev/null +++ b/tests/e2e/rth.test.mjs @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { withChart, assertNoPageErrors } from './helpers.mjs'; + +test('SPY open and close land on weekday Eastern hours, never the weekend', + { timeout: 180000 }, async () => { + await withChart(async page => { + const marks = await page.evaluate(() => { + const monday = Date.UTC(2026, 7, 17, 0, 0) / 1000; + const sunday = Date.UTC(2026, 7, 16, 12, 0) / 1000; + return { + monday: ConfluenceChart.rthInstants(monday, monday + 24 * 3600), + sunday: ConfluenceChart.rthInstants(sunday, sunday + 6 * 3600), + dst: ConfluenceChart.epochAtEastern(2026, 3, 9, 9, 30), + open: ConfluenceChart.epochAtEastern(2026, 8, 17, 9, 30), + close: ConfluenceChart.epochAtEastern(2026, 8, 17, 16, 0), + }; + }); + assert.equal(marks.open, 1786973400); + assert.equal(marks.close, 1786996800); + assert.equal(marks.dst, 1773063000); + assert.deepEqual(marks.monday.map(mark => mark.kind), ['open', 'close']); + assert.equal(marks.sunday.length, 0); + assertNoPageErrors(page, assert); + }); + });