From 90fc77df928a2953a312d4202c3f859c7a1ec545 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 25 Aug 2026 05:03:42 -0500 Subject: [PATCH] fix canonical future geometry --- AGENTS.md | 29 ++++++++++ app/api/ws.py | 8 +++ app/bars/session.py | 18 +++++-- docs/implementation.md | 24 +++++++-- static/app.js | 4 +- static/chart.js | 32 ++++++++--- tests/e2e/trendline.test.mjs | 100 ++++++++++++++++++++++++++++++++++- tests/test_session.py | 30 ++++++++++- tests/test_ws_preferences.py | 12 +++++ 9 files changed, 241 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd59173..8240836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,35 @@ trendlines: historical/future canonical price changes, their screen slopes, and whether the future canvas point exists. Include it in a capture when a line looks kinked at the live edge; it separates bad geometry from a bad renderer. +## Future whitespace is a high-risk boundary + +Drawing bugs repeatedly appear to the right of the last real candle. Treat any +change involving future slots, projection, drawing movement, or selection as a +geometry change that needs explicit browser verification. + +- The displayed time scale, a drawing's source-timeframe bar space, and the + server session calendar are different coordinate systems. Never substitute + wall-clock seconds or the displayed grid for canonical source geometry. +- Daily future slots must skip non-session days. Intraday future slots cross the + settlement break and weekend today; an endpoint that looks valid before the + break can become unresolved when real history arrives. +- Do not clamp a future click to the last real candle. Do not let one null or + unpriceable future sample disable an otherwise valid object's whole hit target. +- DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating + from the last two real candles is wrong when sparse-gap slots were inserted. +- Audit every drawing type, not just trendlines: Fibonacci hit testing and body + targets, pinned comments/symbols, keyboard nudges, duplicate, cutoff, handles, + selection glow, and drag persistence have separate paths. +- Tests must cover placement, selection, body drag, endpoint drag, duplicate, + nudge, and cutoff beyond the live edge and across session boundaries. Several + older future-interaction E2E tests remain quarantined against mutable live + state; a skipped test is not protection. + +Relevant history is concentrated near the trendline/future entries in +`docs/implementation.md`. Before fixing another symptom, measure timestamps, +logical/x coordinates, canonical prices, and painted pixels in the user's +viewport; self-consistent chart API numbers have missed real rendering bugs. + ## Keep the two documents current This is a running system under continual change, not a build being executed, so diff --git a/app/api/ws.py b/app/api/ws.py index 5c749c4..e63d222 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -82,6 +82,11 @@ def trendline_series(runtime, tf: Timeframe) -> dict: return value +def displayed_future_times(runtime, tf: Timeframe) -> list[int]: + bars = runtime.store.get(tf, 1) + return future_bucket_starts(bars[-1].t, tf) if bars else [] + + def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: events, events_more = runtime.events.recent() return { @@ -95,6 +100,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: else None, "session_open": session_open(runtime), "trendline_geometry": trendline_geometry(runtime), + "future_times": displayed_future_times(runtime, tf), "events": events, "events_more": events_more, } @@ -171,6 +177,7 @@ async def websocket_endpoint(websocket: WebSocket): { "type": "bar", "tf": tf.value, "bar": bar.to_dict(), + "duration": bucket_duration(bar.t, bar.tf), "session_open": session_open(runtime), "trendline_first_t": ( first_source_t @@ -180,6 +187,7 @@ async def websocket_endpoint(websocket: WebSocket): "trendline_previous_t": previous_source_t, "trendline_future_times": source_geometry.get("future_times", []), "trendline_future_durations": source_geometry.get("future_durations", []), + "future_times": source_geometry.get("future_times", []), } ) elif ( diff --git a/app/bars/session.py b/app/bars/session.py index dbcb1da..d6f0548 100644 --- a/app/bars/session.py +++ b/app/bars/session.py @@ -7,6 +7,7 @@ UTC = ZoneInfo("UTC") EASTERN = ZoneInfo("America/New_York") SESSION_OPEN = time(18, 0) SESSION_CLOSE = time(17, 0) +FUTURE_SLOT_COUNT = 180 def _session_open_local(current: datetime) -> datetime: @@ -40,9 +41,20 @@ def bucket_duration(t: int, tf: Timeframe) -> int: def next_bucket_start(t: int, tf: Timeframe) -> int: - """Next projected source-bar open, with the daily weekend skipped.""" + """Next projected source-bar open, skipping known CME closures.""" if tf is not Timeframe.D1: - return t + tf.seconds + candidate = datetime.fromtimestamp(t + tf.seconds, UTC).astimezone(EASTERN) + weekday = candidate.weekday() + wall_time = candidate.timetz().replace(tzinfo=None) + if weekday == 5: # Saturday -> Sunday open. + candidate = datetime.combine(candidate.date() + timedelta(days=1), SESSION_OPEN, EASTERN) + elif weekday == 6 and wall_time < SESSION_OPEN: + candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN) + elif weekday in {0, 1, 2, 3} and SESSION_CLOSE <= wall_time < SESSION_OPEN: + candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN) + elif weekday == 4 and wall_time >= SESSION_CLOSE: + candidate = datetime.combine(candidate.date() + timedelta(days=2), SESSION_OPEN, EASTERN) + return int(candidate.timestamp()) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) candidate = current.date() + timedelta(days=1) # Daily futures sessions open Sunday through Thursday. @@ -51,7 +63,7 @@ def next_bucket_start(t: int, tf: Timeframe) -> int: return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp()) -def future_bucket_starts(t: int, tf: Timeframe, count: int = 64) -> list[int]: +def future_bucket_starts(t: int, tf: Timeframe, count: int = FUTURE_SLOT_COUNT) -> list[int]: values = [] for _ in range(count): t = next_bucket_start(t, tf) diff --git a/docs/implementation.md b/docs/implementation.md index 32cd343..8dd7948 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -990,9 +990,8 @@ duplication shift timestamps in source bars rather than whatever timeframe is currently displayed. Endpoint dragging can still choose a finer-timeframe instant; it becomes a fractional coordinate in the source bar. Snapshots also carry future source slots so editing and duplication use the same timestamps on -both sides; daily slots stay at 18:00 ET and skip the weekend. Intraday future -slots retain the separate, pre-existing limitation that the future axis is not -session-aware. +both sides. Daily slots stay at 18:00 ET and skip the weekend; intraday slots +skip the 17:00-18:00 ET settlement halt and Friday-to-Sunday closure. The first browser implementation rebuilt `historical + future` timestamps for every sampled point. The math test passed, but the next trendline run slowed its @@ -1275,3 +1274,22 @@ click. Levels no longer span the full chart: they start at the first click and run to the right, with a vertical rail at the origin so the swing is obvious. + +### 2026-08-25 — one canonical future calendar + +The browser exposed 180 fixed-interval future slots while source geometry sent +64 session-aware slots. Daily display points therefore included Friday/Saturday, +intraday endpoints could land inside settlement or the weekend, and sufficiently +distant edits crossed the server horizon. Some endpoints looked valid when +created and became unresolved after real post-break history arrived. + +`future_bucket_starts` now owns one 180-slot calendar for display and source +geometry. Daily and intraday slots both skip known CME closures; snapshots and +live bar messages carry the displayed future timestamps, and selected daily bar +updates carry their real 23-hour duration. The browser no longer extrapolates +beyond a supplied canonical horizon. **End trendline here** also preserves an +actual future click instead of reducing it to the nearest real candle. + +Regression coverage pins daily weekends, intraday settlement/weekend rollover, +the shared horizon, display payload, daily live duration, beyond-horizon refusal, +and future cutoff timestamps. diff --git a/static/app.js b/static/app.js index 2213ddc..de48a7c 100644 --- a/static/app.js +++ b/static/app.js @@ -494,6 +494,7 @@ createApp({ if (message.type === 'snapshot') { dataReceivedAt.value = Date.now(); chartApi.setTrendlineGeometry(message.trendline_geometry); + chartApi.setDisplayFutureTimes(message.future_times); chartApi.setBars(message.bars); levels.value = message.levels || []; if (!drawColorInitialized) { @@ -517,7 +518,7 @@ createApp({ dataReceivedAt.value = Date.now(); if (message.trendline_first_t != null) { const geometryCurrent = chartApi.updateTrendlineTime( - message.bar.tf, message.bar.t, null, message.trendline_first_t, + message.bar.tf, message.bar.t, message.duration, message.trendline_first_t, message.trendline_previous_t, message.trendline_future_times, message.trendline_future_durations, ); @@ -525,6 +526,7 @@ createApp({ socket.send(JSON.stringify({ type: 'trendline_geometry' })); } } + chartApi.setDisplayFutureTimes(message.future_times); chartApi.updateBar(message.bar); price.value = message.bar.c; sessionOpen.value = message.session_open ?? sessionOpen.value; diff --git a/static/chart.js b/static/chart.js index 40bee14..5817402 100644 --- a/static/chart.js +++ b/static/chart.js @@ -263,6 +263,7 @@ class ConfluenceChart { this.fibPrimitive = null; this.fibPreview = null; this.futureSpace = null; + this.displayFutureTimes = []; this.snapDot = null; this.snapLabel = null; this.snapLeader = null; @@ -672,11 +673,11 @@ class ConfluenceChart { // A time range names the instant, so later series cannot move it. if (bars.length) { const last = bars[bars.length - 1]; - // Five bars of right-hand breathing room, in seconds. const step = ConfluenceChart.barInterval(bars) || 60; + const future = this.futureTimes(bars); this.pendingView = { from: bars[Math.max(0, bars.length - 160)].t, - to: last.t + step * 5, + to: future[Math.min(4, future.length - 1)]?.time ?? last.t + step * 5, }; this.chart.timeScale().setVisibleRange(this.pendingView); } @@ -702,6 +703,13 @@ class ConfluenceChart { ); } + setDisplayFutureTimes(times) { + this.displayFutureTimes = (times || []).map(Number); + if (this.bars.length && this.futureSpace) { + this.futureSpace.setData(this.timeScaleSlots(this.bars)); + } + } + updateTrendlineTime( tf, time, duration = null, firstTime = null, previousTime = null, futureTimes = [], futureDurations = [], @@ -760,6 +768,10 @@ class ConfluenceChart { futureTimes(bars) { if (bars.length < 2) return []; const last = bars[bars.length - 1]; + const canonical = this.displayFutureTimes + .filter(time => time > last.t) + .slice(0, ConfluenceChart.FUTURE_SLOTS); + if (canonical.length) return canonical.map(time => ({ time, value: 0 })); const step = ConfluenceChart.barInterval(bars); if (step <= 0) return []; const slots = []; @@ -815,7 +827,8 @@ class ConfluenceChart { } return; } - const futureTime = bar.t + ConfluenceChart.FUTURE_SLOTS * step; + const futureTime = this.futureTimes(this.bars).at(-1)?.time; + if (futureTime == null) return; this.futureSpace.update({ time: futureTime, value: 0 }); for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level) @@ -2352,13 +2365,15 @@ class ConfluenceChart { const duration = ConfluenceChart.sourceDuration(source, lower); const elapsed = time - times[lower]; if (!(duration > 0) || elapsed < 0) return null; - if (elapsed > duration && !(allowFuture && lower === times.length - 1)) return null; + if (elapsed > duration && (source.futureTimes?.length + || !(allowFuture && lower === times.length - 1))) return null; return lower + elapsed / duration; } static timeframeTimeAt(source, index) { const times = ConfluenceChart.sourceTimes(source); if (!times.length || index < 0) return null; + if (source.futureTimes?.length && index > times.length - 1) return null; const lower = Math.min(Math.floor(index), times.length - 1); const duration = ConfluenceChart.sourceDuration(source, lower); if (!(duration > 0)) return null; @@ -2864,9 +2879,12 @@ class ConfluenceChart { event.preventDefault(); const point = this.eventPoint(event); if (!point || !this.withinPlot(point) || point.t == null) return; - const cutoff = this.bars.reduce((nearest, bar) => - Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest - ).t; + const lastBar = this.bars[this.bars.length - 1]; + const cutoff = point.t > lastBar.t + ? Math.round(point.t) + : this.bars.reduce((nearest, bar) => + Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest + ).t; this.contextLineId = level.id; this.contextCutoff = cutoff > level.last_t ? cutoff : null; this.contextEndButton.hidden = this.contextCutoff == null; diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index e01bbc8..5214768 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -196,6 +196,90 @@ test('future 5m projections keep their slope after the settlement break', }); }); +test('daily future slots and live duration follow the server session calendar', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const day = 24 * 3600; + const thursday = 1786658400; + const sunday = 1786917600; + const monday = sunday + day; + const bars = [thursday - 3 * day, thursday - 2 * day, thursday - day, thursday] + .map(t => ({ tf: '1d', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true })); + c.setDisplayFutureTimes([sunday, monday]); + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', + series: { '1d': { times: bars.map(bar => bar.t), durations: bars.map(() => 23 * 3600), + future_times: [sunday, monday], future_durations: [23 * 3600, 23 * 3600] } }, + }); + c.updateTrendlineTime( + '1d', sunday, 23 * 3600, bars[0].t, thursday, [monday], [23 * 3600], + ); + const source = c.trendlineSourceSeries.get('1d'); + return { + displayed: c.futureTimes(c.bars).map(point => point.time), + duration: source.durations.at(-1), + beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1), + }; + }); + + assert.deepEqual(result.displayed, [1786917600, 1787004000]); + assert.equal(result.duration, 23 * 3600); + assert.equal(result.beyondHorizon, null); + assertNoPageErrors(page, assert); + }); + }); + +test('ending a trendline in future whitespace keeps the future timestamp', + { timeout: 180000 }, async () => { + await withChart(async page => { + const targetTime = await page.evaluate(() => { + const c = window.__chart; + const step = 300; + const bars = Array.from({ length: 30 }, (_, index) => ({ + tf: '5m', t: 400000 + index * step, o: 100, h: 101, l: 99, c: 100, + v: 1, closed: true, + })); + const future = Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step); + const line = { + id: 'future-cutoff', kind: 'manual', tf: '5m', side: 'support', label: 'cutoff', + anchor_t: bars[5].t, anchor_p: 99.5, last_t: bars[15].t, slope: 0.25 / 3000, + cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, + }; + c.setDisplayFutureTimes(future); + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step, + future_times: future, future_durations: future.map(() => step) } }, + }); + c.syncLevels([line]); + c.setSelectedLine(line.id); + return future[2]; + }); + await page.waitForTimeout(100); + const result = await page.evaluate(time => { + const c = window.__chart; + const line = c.levels.find(level => level.id === 'future-cutoff'); + const plot = c.plotCanvas().getBoundingClientRect(); + const event = { + clientX: plot.left + c.chart.timeScale().timeToCoordinate(time), + clientY: plot.top + c.candles.priceToCoordinate(c.sourceLinePriceAt(line, time)), + preventDefault() {}, + }; + const point = c.eventPoint(event); + c.showContextMenu(event); + return { time, cutoff: c.contextCutoff, point, within: point && c.withinPlot(point), + selected: c.selectedLineId, y: event.clientY - plot.top }; + }, targetTime); + + assert.equal(result.cutoff, result.time, + `future cutoff was clamped to the final candle: ${JSON.stringify(result)}`); + assertNoPageErrors(page, assert); + }); + }); + test('a short final data gap does not kink a future trendline', { timeout: 180000 }, async () => { await withChart(async page => { @@ -214,6 +298,9 @@ test('a short final data gap does not kink a future trendline', line_width: 2, provisional: false, }; const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute); + c.setDisplayFutureTimes(Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, + )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } }, @@ -265,6 +352,9 @@ test('painted canvas pixels keep the same slope through the live edge', slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 3, provisional: false, }; + c.setDisplayFutureTimes(Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, + )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', @@ -315,6 +405,10 @@ test('a manual line series owns its future 5m slots without an SVG projection', slope: 1 / (4 * step), cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; + const initialFuture = Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * step, + ); + c.setDisplayFutureTimes(initialFuture); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } }, @@ -325,6 +419,10 @@ test('a manual line series owns its future 5m slots without an SVG projection', const before = c.levelSeries.get(line.id).series.data(); const futurePoint = before.find(point => Number(point.time) === futureTime); const next = { ...bars.at(-1), t: bars.at(-1).t + step }; + const nextFuture = Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => next.t + (index + 1) * step, + ); + c.setDisplayFutureTimes(nextFuture); c.updateBar(next); const after = c.levelSeries.get(line.id).series.data(); return { @@ -332,7 +430,7 @@ test('a manual line series owns its future 5m slots without an SVG projection', expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)), futureSlotBeforeLine, tailTime: Number(after.at(-1).time), - expectedTailTime: next.t + ConfluenceChart.FUTURE_SLOTS * step, + expectedTailTime: nextFuture.at(-1), visibleProjection: Boolean(document.querySelector('.chart-projections')), }; }); diff --git a/tests/test_session.py b/tests/test_session.py index 3081322..0229780 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,13 @@ from zoneinfo import ZoneInfo import pytest from app.bars.models import Timeframe -from app.bars.session import bucket_duration, bucket_start, next_bucket_start +from app.bars.session import ( + FUTURE_SLOT_COUNT, + bucket_duration, + bucket_start, + future_bucket_starts, + next_bucket_start, +) UTC = ZoneInfo("UTC") ET = ZoneInfo("America/New_York") @@ -69,3 +75,25 @@ def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern(): sunday = epoch("2026-08-16T18:00:00", ET) assert next_bucket_start(thursday, Timeframe.D1) == sunday + + +def test_intraday_future_geometry_skips_the_settlement_halt(): + monday_last = epoch("2026-08-10T16:55:00", ET) + monday_reopen = epoch("2026-08-10T18:00:00", ET) + + assert next_bucket_start(monday_last, Timeframe.M5) == monday_reopen + + +def test_intraday_future_geometry_skips_the_weekend(): + friday_last = epoch("2026-08-14T16:55:00", ET) + sunday_reopen = epoch("2026-08-16T18:00:00", ET) + + assert next_bucket_start(friday_last, Timeframe.M5) == sunday_reopen + + +def test_future_geometry_uses_the_shared_180_slot_horizon(): + start = epoch("2026-08-13T18:00:00", ET) + future = future_bucket_starts(start, Timeframe.D1) + + assert len(future) == FUTURE_SLOT_COUNT == 180 + assert all(datetime.fromtimestamp(value, ET).weekday() in {6, 0, 1, 2, 3} for value in future) diff --git a/tests/test_ws_preferences.py b/tests/test_ws_preferences.py index f59c737..5af2548 100644 --- a/tests/test_ws_preferences.py +++ b/tests/test_ws_preferences.py @@ -105,6 +105,18 @@ def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path): assert source["duration"] == 1800 assert source["future_times"][:2] == [4600, 6400] assert source["future_durations"][:2] == [1800, 1800] + assert len(source["future_times"]) == 180 + + +def test_daily_snapshot_carries_session_aware_display_future_times(tmp_path): + runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json")) + thursday = 1786658400 # 2026-08-13 18:00 America/New_York + runtime.store.put(Bar(Timeframe.D1, thursday, 1, 1, 1, 1, 1, True, "/ES", "test")) + + message = snapshot(runtime, Timeframe.D1) + + assert len(message["future_times"]) == 180 + assert message["future_times"][0] == 1786917600 # Sunday 18:00 Eastern def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):